diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..1c36f94 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,95 @@ +# Can the repository laws go red? +# +# Every other mechanism here asks whether a law PASSES. This one asks the +# opposite question, and it is the question this repository's own history says +# is the dangerous one. Three defects found in these laws were laws that passed +# while what they claimed was false: an obligation that +# lost its `red:` row and still qualified, a green route seated by any test that +# happened to be in the file, and a manifest table made invisible to the +# topology reader by a trailing comment. None of the three was a failing check. +# Each was a check whose numerator and denominator moved together, so the +# printed PASS stayed exactly as true-looking as it had been. +# +# A mutation run is the only mechanism in this repository that attacks that +# directly. It damages one decision in a source, rebuilds, and runs the tests: a +# mutant the tests CATCH is a decision some test is actually about, and a mutant +# that SURVIVES is a decision nothing executed would have noticed being wrong. +# Survivors are the finding. They are not repaired by the run and they are not +# repaired by whoever reads it in the same breath — a surviving mutant is +# evidence debt, named at its own home, and the repair is a test or a type there. +# +# WHY `xtask` FIRST, and the scope below says so in one line. xtask is the plane +# whose checks most need proof they can go red, because a repository law is the +# one kind of claim here with no second reader: a test that stops testing is +# caught by the law that counts it, and a law that stops enforcing is caught by +# nothing. It is also the crate all three defects above were found in. The +# machine, the services, and the judge are out of scope for now and their +# opening condition is this run being cheap enough to widen. +# +# WHY THIS IS NOT A GATE, and never becomes one. A mutation run rebuilds and +# retests once per mutant, so its wall-clock cost is the tests' cost multiplied +# by the number of decisions in the sources. That does not belong between a +# commit and a merge: a contributor waiting on it would learn to route around +# it, and a timeout in it would read as a verdict about the change. It runs on a +# schedule in `.github/workflows/mutation.yml`, where a survivor means "go look" +# rather than "this change is bad" — the same split the dependency workflow +# already makes between its graph job and its advisory job. +# +# WHAT THIS DOES NOT CLAIM. A mutant that is CAUGHT says some test noticed one +# damaged decision; it says nothing about whether that test is about the right +# thing. A mutant reported UNVIABLE says the damaged source did not compile, and +# on this tree that is most of them — a strongly typed reader refuses most +# damage before any test is reached. Unviable is not coverage and it is not a +# gap: it is a question the run never got to ask, and reading a low survivor +# count off a high unviable count would be reading coverage out of silence. +# +# WHERE THIS FILE IS COUNTED: in the roster `xtask/src/main.rs` holds. +# `alarm-artifacts-are-present-and-distinct` reads this file and refuses when it +# is deleted or emptied, which is the opening condition `.config/nextest.toml` +# wrote once for both surfaces, now discharged. Its own report still has a +# denominator of a different kind — the run derives it, every mutant examined, +# split into caught, survived, timed out and did-not-build, and the job prints +# it — and that one is published by a run rather than joined by a law, so what +# refuses when this file goes is the roster entry and not the report. + +# The scope, and the whole of it. Mutants are generated from these sources +# alone; the tests that judge them are the mutated package's own, which for +# every file here is xtask's. +# +# THE ONE THING THIS LINE CANNOT SAY is which PACKAGES a run examines — the +# configuration has no key for it, so the selection is an argument. MEASURED: +# `cargo mutants` with no selection takes the root package alone, this glob +# matches nothing inside it, and the run finds no mutant and exits 0. It does say +# so, on a WARN line — and a warning that fails nothing is what a job reads as +# success, which is the whole difference between a diagnostic and a refusal. +# `cargo mutants --workspace` is therefore the invocation, on a working machine +# exactly as in the hosted job, and the job COUNTS what it examined rather than +# trusting that it examined something. +examine_globs = ["xtask/**/*.rs"] + +# CHOSEN: cap the lints, and this is the setting that decides what the run +# MEASURES. `.cargo/config.toml` makes a surviving warning fatal, so without +# this a mutant that merely trips `unused_variables` or `clippy::let_and_return` +# fails to build and is reported UNVIABLE — a verdict that reads as "not a +# coverage gap" when the tool never got far enough to ask. Capping the lints +# asks the one question a mutation run is for: does a TEST catch it. What the +# lint wall catches is a different seat with its own stage in the entry bar, and +# it is not weakened by anything here — this flag reaches the throwaway build in +# a scratch directory and no build of this tree. +cap_lints = true + +# CHOSEN: write the report inside `target`, which is already ignored. The +# default puts `mutants.out` beside `Cargo.toml`, and the qualification road +# ends by refusing a checkout that does not match what is committed — so the +# default would make a working machine's mutation run fail the next `cargo xtask +# qualify` for a reason that has nothing to do with the tree. A tool that writes +# into the checkout is a tool that has to be remembered; one that writes into +# `target` does not. +output = "target" + +# NOT SET, ON THE RECORD: `test_tool`. nextest would run these suites faster, +# and `.config/nextest.toml` configures it two directories away. It is left at +# cargo deliberately: a mutation result must not depend on whether the second +# harness works, because the two surfaces landed together and a common cause +# would take both readings out at once. It is adopted the day the second harness +# has a history of its own. diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..187160f --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,235 @@ +# The second harness. It is not the entry bar and it does not become one. +# +# `cargo xtask qualify` is the entry bar and the only spelling of it. Its +# `tests` stage runs `cargo test --locked --workspace` with the pinned +# toolchain and nothing else, and that stage is untouched by this file. nextest +# is a separately installed binary, so folding it into qualify would make the +# bar depend on what a working machine happens to have — the reason cargo-deny +# runs beside the bar rather than inside it, written once in `deny.toml` and not +# restated here. This file configures a SECOND reading of the population that +# stage already runs, and `.github/workflows/harness.yml` is where it runs. +# +# WHY A SECOND READING IS WORTH HAVING AT ALL. The hosted qualify job runs two +# hosts because the road claims to be one road and two hosts can disagree. Two +# harnesses stand on that same argument one level in. `cargo test` runs every +# test of a binary as a thread inside ONE process; nextest runs each test in a +# process of its own. The two can therefore only disagree where a result depends +# on something a process shares — a static, an environment variable, a working +# directory, a file another test wrote. The repository laws walk real trees and +# write scratch directories, so that is not a theoretical class here. +# +# MEASURED, on the tree this file lands with, on the pinned toolchain: +# - `cargo test --locked --workspace` executes 533 harness tests and 3 +# doctests, and passes; +# - `cargo nextest run --locked --workspace` executes 533, and passes; +# - the two executed sets are EQUAL name for name — the difference is empty in +# both directions, so no test changed hands and none was quietly dropped. +# The second harness found nothing the first one did not. That is the honest +# result of the first reading, and it is why this is an alarm standing beside +# the bar rather than a bar of its own. +# +# ONE TEST IN THE TREE IS EXECUTED BY NEITHER, and it is named here rather than +# left for a reader to find in a summary line: `the_nextest_timeout_reversal`, +# in `xtask/src/checks/alarms.rs`, is the SUBJECT of the planted reversal at the +# bottom of this file. It carries `#[ignore]`, so both readings above report it +# skipped and neither pays for it, and the reversal step is the one run that +# selects it. Both counts were re-measured with it in the tree. +# +# WHAT NEXTEST DOES NOT EXECUTE, and it is the first thing to know rather than a +# footnote: DOCTESTS. `cargo nextest run` neither builds nor runs them, and this +# repository's three doctests are load-bearing. `CLOSED_REGISTER_ROW_CEILING`'s +# example in `src/lib.rs` is the ONE place the stamp's current authoring profile +# is written down, and its whole job is to fail when the supply is extended; +# `closed_register`'s example and band 02's `scope_guard_version` example are +# the other two. Replacing `cargo test` with nextest and saying nothing would +# delete three executed controls and print the same green — the exact defect +# class this repository exists to refuse. +# +# That is why nextest SUPPLEMENTS the `tests` stage and does not replace it. The +# stage is untouched, it still executes all 536 on both hosts, and the doctests +# lose nothing. What follows is that this configuration's population is a strict +# SUBSET of the bar's — 533 of 536 — and the harness job deliberately runs no +# doctest leg of its own, because a third execution of a control the bar already +# runs twice would be one claim seated twice. A reader comparing the two summary +# lines is meant to find them different, and the difference is exactly those +# three. +# +# WHAT THIS FILE CLAIMS: that the workspace's harness tests pass under +# process-per-test isolation, and that the configuration below is the one a run +# used. WHAT IT DOES NOT CLAIM: anything about doctests; anything about a test a +# macro generated or deleted, because nextest lists what the compiled binaries +# report and this repository's readers already state that ceiling; and anything +# about the entry bar, which is `cargo xtask qualify` and is defined by the +# ordered stage table in `xtask/src/qualification.rs` alone. +# +# WHERE THIS FILE IS COUNTED: in the roster `xtask/src/main.rs` holds, which +# that file states is the only statement of how many repository laws there are. +# `alarm-artifacts-are-present-and-distinct` reads this file, +# `.cargo/mutants.toml`, and the two workflows that call them, and refuses when +# one is deleted, when one is emptied, or when the `reversal` profile below +# stops departing from `default`. It is still in neither RED-TWIN denominator, +# for the reason `deny-reversal.toml` gives about itself — the red populations +# xtask joins against are testpak's tests and its compile-fail fixtures, and a +# configuration beside a hosted workflow is neither — and the roster is the seat +# the dependency gate's own artifacts took for exactly that reason. +# +# The opening condition written here has therefore been discharged, and it was +# the one admission requirement this surface arrived without. What the law +# establishes is that the FILES are there, and deliberately not that any hosted +# step still runs them: a step somebody edits out of `harness.yml` leaves every +# file exactly where it is, and that removal is caught by review alone. That +# second half is the same road the dependency gate names and does not reach — a +# hosted run publishing the roster of what it EXECUTED — and +# `xtask/src/checks/alarms.rs` carries the ceiling as an executed test rather +# than as this sentence. + +# CHOSEN: the exact version, and it is a staleness guard rather than +# housekeeping. A key this file writes that a nextest no longer reads would +# otherwise be a decision that stopped applying in silence — the failure mode +# `clippy.toml` says it could not build a guard for. nextest refuses to run at +# all when the binary is older than the version its configuration requires, which +# is the tool's documented behaviour and NOT measured here: proving it needs an +# older nextest and there is none on any machine this has run on. What IS +# measured is that 0.9.132 accepts every key below. Raising the pin is a visible +# act either way. +nextest-version = "0.9.132" + +[profile.default] +# CHOSEN: report every failure. nextest's own default stops the run at the first +# one. The qualification road already settles this question and settles it the +# other way for a population like this: fail fast ACROSS stages that share a +# cause, report everything WITHIN a stage whose findings do not. Tests are +# independent of one another, so a run that stopped at the first red would cost +# a round trip per failure to learn what one run already knew. +fail-fast = false + +# CHOSEN: zero, restating nextest's default AS a decision, because this is the +# one setting whose wrong value would be invisible. A retried test that passes +# on the second attempt is reported as a pass, and a flaky test converted into a +# green one is precisely the silence this repository refuses. A test that does +# not hold every time is a test whose claim is false; the repair is at the test. +retries = 0 + +# CHOSEN: name a test that runs long, and never kill one. `period` makes nextest +# print a SLOW line for anything past it, which is a signal; `terminate-after` +# would convert that signal into a failure. +# +# NOT ADOPTED, ON THE RECORD: `terminate-after`, over THIS population. +# `compile_refusals` shells out to rustc once per compile-fail fixture and +# MEASURED takes 20.8 seconds on this working machine over an already-built +# tree; a cold hosted runner with no cache, which is what this repository +# deliberately runs, is slower by a factor nobody here has measured, and the +# same test on the same machine is faster again once another run has warmed what +# it compiles. That spread is the whole point. A kill threshold set against an +# unmeasured worst case is a gate that fails on how busy a runner was, and a +# gate that reds for a reason unrelated to the tree teaches its readers to +# ignore it. It is adopted here the day a hosted run publishes per-test +# durations to set it against, and not before. Where `terminate-after` IS used +# below, it is set against a test whose duration is an INSTRUCTION rather than a +# measurement, which is a different thing entirely. +slow-timeout = { period = "60s" } + +# CHOSEN: print a failing test's output where it happened AND again at the end. +# A hosted log is read from the bottom, and a run with `fail-fast = false` can +# put a thousand lines between a failure and the summary. +failure-output = "immediate-final" + +# THE PLANTED REVERSAL: a profile that is deliberately wrong, kept on purpose. +# +# The lawful profile above establishes one thing when it passes: the tests hold +# today. It establishes nothing about whether this configuration is READ. A key +# nextest stopped honouring, a file moved out from under the tool's search, a +# profile name that stopped resolving — every one of those leaves the same green +# summary as a run that used every line above. A gate whose refusal has never +# been executed is a gate nobody has watched work, and this repository does not +# admit one; `deny-reversal.toml` exists for that reason and this profile exists +# for the same one. +# +# WHY A PROFILE HERE RATHER THAN A SEPARATE FILE, which is where the dependency +# gate puts its reversal. A second file would replace this configuration +# wholesale, so a refusal it produced would prove that nextest refuses and +# nothing about whether THIS file is live. A reversal profile is read out of the +# same file the lawful profile is read out of: the day this file stops being +# found, the reversal stops refusing and the step that requires a refusal turns +# red. One artifact, both facts. +# +# WHAT IS PLANTED. The profile selects exactly ONE test — +# `the_nextest_timeout_reversal`, in `xtask/src/checks/alarms.rs`, whose entire +# body is an instruction to sleep for thirty seconds — and sets a kill threshold +# of one second against it. The defect is the sleep, and a sleep is the same +# length on a cold runner, a warm one, and a busy one. Nothing about it is +# compiled, resolved, cached, or reused, so there is no state any earlier step +# can put the host in that shortens it. +# +# THE PLANT THIS REPLACES WAS A TIMING RACE, and a hosted run is what established +# that rather than a review. It selected `binary(compile_refusals)` — the slowest +# test in the repository, measured in the tens of seconds — and killed it at one +# second, calling a factor of seventeen a margin. The margin was never the test's. +# It was the COMPILATION that test pays for, and the positive step in this same +# job pays it first: on the hosted runner at `f25f33d` the reversal run found +# every compilation product already warm, finished inside the second, PASSED, and +# exited 0 — so the step that requires a refusal went red, and the newly added +# alarm's own negative control was the only thing on the board that had failed. +# That is the finding this profile was rewritten from. A reversal whose +# activation depends on a timing margin is a reversal that can stop refusing in +# silence, which is the exact defect class this repository exists to refuse. +# +# THE SUBJECT IS `#[ignore]`d, and that is what keeps the plant off every other +# run. `cargo test`, the entry bar's `tests` stage, the second harness's own +# positive step, and every mutation rebuild report it skipped and pay nothing for +# it. Two consequences follow, and both are measured rather than assumed: +# +# - `run-ignored` is NOT a configuration key. MEASURED on 0.9.132: writing it +# in a profile prints `ignoring unknown configuration key: +# profile.reversal.run-ignored` and changes nothing, and a filterset cannot +# name an ignored test either. So the reversal step spells `--run-ignored +# only` on the command line. That flag does not SELECT anything — the +# `default-filter` below is the selection, and `only` merely lifts the skip +# over a population this repository has exactly one member of. `only` rather +# than `all` on purpose: a subject that lost its `#[ignore]` would silently +# join every ordinary run under `all`, and under `only` it empties the +# selection and takes the step red on the next run. +# - `no-tests` is not a configuration key either, and it is the one that must +# not be left to a default. MEASURED: `no-tests = "fail"` in a profile prints +# the same `ignoring unknown configuration key` line, so the step spells +# `--no-tests fail`. An empty selection then refuses instead of reporting a +# green run over nothing — which is what a renamed or deleted subject would +# otherwise buy: `0 tests run: 0 passed`, and a summary line no reader +# distinguishes from work. +# +# MEASURED, on the pinned toolchain, with the tree this file lands with: +# `cargo nextest run --locked --workspace --profile reversal --run-ignored only +# --no-tests fail` starts one test and skips 533, prints `TERMINATING` at 1.000s +# and `TIMEOUT` against `xtask::bin/xtask +# checks::alarms::tests::the_nextest_timeout_reversal`, reports `1 timed out`, +# and exits 100. The step requires THREE things — the exit code, +# the `timed out` diagnostic, and that test's NAME — for the reason the +# dependency gate's reversal step requires more than a code: nextest exits +# non-zero for a usage error and for a configuration it could not read, so a code +# alone would accept a run that never reached a test, and a code with a +# diagnostic would still accept a run that timed out something else. +# +# THE ONE WAY THIS REVERSAL CAN STOP BEING ABOUT ANYTHING is the subject being +# renamed or deleted, and it does not go silent when that happens: the selection +# empties, `--no-tests fail` refuses, and the step reds. It cannot stop being +# about anything by becoming FAST, which is the way the plant this replaced +# stopped. +# +# WHAT THIS REVERSAL COVERS: that this file is read, that a profile in it is +# resolved and applied, that a filterset written here decides what runs, that +# `terminate-after` is honoured, and that nextest's refusal path exits non-zero +# and says why. What it does NOT cover: `fail-fast`, `retries`, and +# `failure-output` above — one test is selected here, so nothing about how a run +# behaves after a second failure is exercised, and any of the three could stop +# being honoured tomorrow leaving both this run and the lawful one reading +# exactly as they read now. Those three stand on a positive invocation alone. +# That is a debt NAMED here rather than discharged, and the shape that would +# discharge it is one more deliberately wrong profile per setting, each requiring +# its own exit code and its own diagnostic. +# +# Only the two keys that ARE the plant are written below. A profile inherits +# every other setting from `default`, so restating one here would be a second +# place the lawful value lives and the first thing to drift. +[profile.reversal] +default-filter = 'test(the_nextest_timeout_reversal)' +slow-timeout = { period = "1s", terminate-after = 1 } diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index ae1448a..115c31f 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -28,12 +28,13 @@ on: # same opening condition — which is written out once, there, rather than twice # here. # - # The asymmetry was the defect. Two workflows guard this trunk and `graph` is - # required to pass before a merge exactly as `qualify` is, but only one of the - # two carried the bootstrap cover, and nothing anywhere stated a reason for the - # difference. A gate that a direct push walks past is not a gate, whatever it + # The asymmetry was the defect. When this was written two workflows guarded + # this trunk and `graph` was required to pass before a merge exactly as + # `qualify` is, and only one of the two carried the bootstrap cover, with + # nothing anywhere stating a reason for the difference. A gate that a direct push walks past is not a gate, whatever it # reports on the pull requests it does see. The two triggers were added for one - # unexecuted refusal and they retire together. + # unexecuted refusal and they retire together — along with every other trigger + # of the same shape, wherever a workflow carries one. push: branches: [main] # Weekly, and on demand. The advisory job needs a trigger that is not a change, diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml new file mode 100644 index 0000000..0e2cd43 --- /dev/null +++ b/.github/workflows/harness.yml @@ -0,0 +1,171 @@ +# The second harness, and the doctests it cannot reach. +# +# This is NOT the entry bar. `cargo xtask qualify` is the entry bar and the only +# spelling of it, and nothing here restates a stage of it. nextest is a +# separately installed binary, so folding it into qualify would make the bar +# depend on what a working machine happens to have — the same reason cargo-deny +# runs beside the bar rather than inside it, written once in `deny.toml` and not +# restated here either. +# +# WHY a second reading of one battery is worth having, WHAT it executes, and — +# first — what it does NOT execute, is written once in `.config/nextest.toml`, +# beside the configuration a run reads. This file is the caller. It runs two +# things: the second harness, and the planted reversal that proves the +# configuration above is live. +name: harness + +on: + pull_request: + # And direct pushes to the trunk: the same trigger `.github/workflows/qualify.yml` + # carries, for the same reason and under the same opening condition, which is + # written out once there rather than a third time here. + push: + branches: [main] + # Weekly, and on demand. The claim this supports is nextest's own + # availability: this job installs a pinned version of a tool from a registry, + # and a tree that has not changed can still stop being testable this way when + # something outside it moves. A run on a schedule is what turns that into a + # morning's alarm rather than a surprise on somebody's pull request. The hour + # is its own: three workflows starting at once on the same runner pool would + # be three jobs waiting on each other for no reason. + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: + +# One live run per ref, EXCEPT on the trunk, for the reason +# `.github/workflows/qualify.yml` states about its own runs: a superseded +# pull-request run costs nothing to cancel, and the trunk's run is the only +# record nobody can reconstruct later. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# The run reads the tree and writes nothing back. +permissions: + contents: read + +jobs: + second-harness: + # ONE host, deliberately, and the asymmetry with `qualify` is not an + # oversight. The two-host matrix there exists because the qualification road + # CLAIMS to be one road across platforms and that claim needed executing. + # This job claims nothing about platforms: it claims that the workspace's + # harness tests hold under process-per-test isolation. A second host would + # double the cost of a job that is not the entry bar to re-execute a claim + # the entry bar already executes on both. + runs-on: ubuntu-latest + # The default is six hours. Nothing here is a six-hour job, and this number + # is generous against a cold, uncached compile of the whole workspace plus + # building nextest itself from source, not tuned to the current runtime. + timeout-minutes: 45 + steps: + # Pinned to the exact commit `v4` named, for the reason every action pin + # in this repository carries: a tag is a movable label and a commit is not. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the pinned toolchain + run: | + rustup toolchain install + rustup show + + # Built from source at an exact version, exactly as cargo-deny is, and for + # the same reason: a version range is a decision deferred to whoever + # resolves next. This is the version MEASURED on a working machine when + # `.config/nextest.toml` was written; that file requires it by name, and + # what that requirement does and does not establish is stated there. + - name: Install cargo-nextest + run: cargo install cargo-nextest --version 0.9.132 --locked + + # The positive control. `--workspace` is load-bearing here in the way it + # is load-bearing for cargo-deny, and its absence is silent. MEASURED on + # the pinned toolchain: without the flag the root package is the only one + # run — 183 tests rather than 533 — and both runs print the same shape of + # green summary and exit 0. + # + # An empty run cannot pass this step. MEASURED on the pinned toolchain: a + # selection that matches no test prints `error: no tests to run` and exits + # 4, so a filter that stopped matching is a red rather than a green over + # nothing. + # + # One test in the tree is not in this population and the summary line says + # so — `1 test skipped`. It is `the_nextest_timeout_reversal`, the subject + # of the planted reversal below, and `.config/nextest.toml` names it beside + # the measurement that put both counts here. + - name: The second harness must pass + run: cargo nextest run --locked --workspace + + # NO DOCTEST LEG HERE, AND THE ABSENCE IS A DECISION. `cargo nextest run` + # neither builds nor runs doctests, and this repository's three are + # load-bearing — which is exactly why nextest SUPPLEMENTS the entry bar's + # `tests` stage rather than replacing it. That stage still runs `cargo test + # --locked --workspace`, on both hosts, and it executes the doctests as it + # always did. A `cargo test --doc` step here would be a third execution of + # a control the bar already runs twice, establishing nothing the bar does + # not, and this repository does not seat a claim twice. + # + # So this job's population is a STRICT SUBSET of the bar's, deliberately: + # 533 of the 536 the bar executes. The three it does not reach are named in + # `.config/nextest.toml`, with the measurement that put that number there. + # A reader comparing the summary lines is meant to find them different. + + # THE PLANTED REVERSAL. The steps above establish that the tests hold + # today; they cannot establish that the committed configuration is READ, + # because a key nextest stopped honouring and a file it never found leave + # exactly the same green summary as a run that used every line of it. + # `.config/nextest.toml`'s `reversal` profile is deliberately wrong, and + # this step fails the job when nextest SUCCEEDS under it. + # + # THE DEFECT IS A SLEEP, and the two flags below are what reach it. The + # profile's `default-filter` selects one test whose body sleeps thirty + # seconds and kills it at one; `--run-ignored only` lifts the `#[ignore]` + # that keeps that test out of the positive run above; `--no-tests fail` + # makes an empty selection refuse rather than report a green run over + # nothing. Neither is a configuration key — MEASURED, and written down in + # `.config/nextest.toml` beside the profile — which is why they are spelled + # here and not there. + # + # THE PLANT THIS REPLACES was the elapsed runtime of an ordinary test, and + # this job is where that failed: the positive step above warms every + # compilation product a compile-fail fixture pays for, so the run that was + # supposed to be killed finished inside the second and exited 0. A margin + # is not a mechanism. + # + # THREE THINGS ARE REQUIRED, because no two of them are enough. nextest + # exits non-zero for a usage error and for a configuration it could not + # read, so the code alone would accept a run that never reached a test; the + # code with the diagnostic would still accept a run that timed something + # ELSE out. MEASURED on the pinned toolchain: exit 100, `timed out`, and + # `the_nextest_timeout_reversal` in the transcript is what a run that + # refused for the reason that was planted looks like. + # + # WHAT THIS COVERS AND WHAT IT DOES NOT is written in + # `.config/nextest.toml`, beside the defect, where a reader deciding + # whether to trust a green log will be looking. + - name: The planted reversal must refuse + run: | + set +e + transcript=$(cargo nextest run --locked --workspace --profile reversal --run-ignored only --no-tests fail 2>&1) + code=$? + set -e + printf '%s\n' "$transcript" + if [ "$code" -ne 100 ]; then + echo "::error::nextest exited ${code} against the planted reversal profile; a reversal that does not refuse is a gate nobody has watched work" >&2 + exit 1 + fi + case "$transcript" in + *"timed out"*) ;; + *) + echo "::error::nextest refused without reporting a timed-out test; the refusal is not the one that was planted" >&2 + exit 1 + ;; + esac + case "$transcript" in + *"the_nextest_timeout_reversal"*) ;; + *) + echo "::error::nextest timed a test out and it was not the planted subject; whatever this run refused about, it is not the defect that was planted" >&2 + exit 1 + ;; + esac + echo "the planted reversal refused: exit 100, timed out, the_nextest_timeout_reversal" diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..7bf5bf1 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,170 @@ +# Can the repository laws go red? +# +# This is NOT the entry bar and it is NOT a gate. `cargo xtask qualify` is the +# entry bar and the only spelling of it. WHY a mutation run is worth having +# here, WHY `xtask` is its scope, and WHY it may never sit between a commit and +# a merge are written once in `.cargo/mutants.toml`, beside the configuration a +# run reads. This file is the caller. +# +# WHAT A RED HERE MEANS. Nothing about the change that is in flight, because no +# change is: this workflow has no trigger a change can pull. A red means the +# alarm itself did not work — the scope stopped matching sources, the tool +# stopped killing anything, or the run never finished. SURVIVING MUTANTS DO NOT +# TURN IT RED. They are the finding, they are printed in full, and they are +# evidence debt owed at the home of the source that carries them; a job that +# went red on the first survivor would be red from the day it landed, and an +# alarm that is always red is an alarm nobody reads. +name: mutation + +on: + # Weekly, and on demand, and NOTHING ELSE. A mutation run rebuilds and retests + # once per mutant; on a pull request it would be a wait a contributor learns to + # route around, and a timeout in it would read as a verdict about their change. + # + # The hour is its own. Three workflows starting together would be three jobs + # queueing behind one another for no reason. + schedule: + - cron: "0 8 * * 1" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +# The run reads the tree and writes nothing back. +permissions: + contents: read + +jobs: + xtask: + # NAMED BY WHAT IT RUNS ON, never by what it skips, and the triggers above + # already allow nothing else — so this condition is redundant TODAY and is + # written anyway. `.github/workflows/dependencies.yml` records what the other + # form costs: its advisory job was conditioned as "everything that is not a + # pull request", and the day a push trigger was added to that file the job + # silently gained an event nobody gave it. A skip-list inherits every trigger + # its file ever grows; a positive list gains none it was not written. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + # MEASURED on a warm working machine: 128 mutants over one module in 3 + # minutes at two jobs, and the whole `xtask` scope is 648. A cold hosted + # runner with no cache is slower by a factor nobody here has measured, so + # this number is generous against an unmeasured worst case rather than tuned + # to the current runtime — and a run that reaches it is a run that stopped + # making progress, which is exactly the "go look" this job exists to raise. + timeout-minutes: 180 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the pinned toolchain + run: | + rustup toolchain install + rustup show + + # Built from source at an exact version, exactly as cargo-deny and + # cargo-nextest are, and for the same reason: a version range is a + # decision deferred to whoever resolves next. This is the version MEASURED + # on a working machine when `.cargo/mutants.toml` was written. + - name: Install cargo-mutants + run: cargo install cargo-mutants --version 27.0.0 --locked + + # THE RUN, AND THE POSITIVE CONTROL OVER IT. + # + # `--workspace` is load-bearing and its absence costs nothing. MEASURED: + # without it cargo-mutants takes the root package as the only one to + # examine, the scope in `.cargo/mutants.toml` matches nothing in it, and + # the run finds no mutant and exits 0 — the same exit code as a run that + # examined everything, under a WARN line that fails nothing. That is why + # this step counts what was examined instead of trusting that something + # was. + # + # TWO EXIT CODES ARE THE RUN HAPPENING. MEASURED: cargo-mutants exits 2 + # when it found a mutant nothing caught, which is a finding rather than a + # failure of the alarm. Exit 0 is the other half — every mutant caught — + # and it is NOT measured here, because this tree has survivors today. Any + # other code is the alarm not working, and it stops the job. + # + # `-j 2` builds in two scratch directories at once. More would trade the + # runner's disk for a speed this job does not need, since nothing waits on + # it. + - name: The mutation run + run: | + set +e + cargo mutants --workspace -j 2 + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then + echo "::error::cargo-mutants exited ${code}; 0 and 2 are the run happening, and anything else is the alarm not working" >&2 + exit 1 + fi + report=target/mutants.out + for roster in caught missed timeout unviable; do + if [ ! -f "${report}/${roster}.txt" ]; then + echo "::error::${report}/${roster}.txt is not there; the run wrote no report, so nothing below is a measurement" >&2 + exit 1 + fi + done + caught=$(wc -l < "${report}/caught.txt") + missed=$(wc -l < "${report}/missed.txt") + timedout=$(wc -l < "${report}/timeout.txt") + unviable=$(wc -l < "${report}/unviable.txt") + examined=$((caught + missed + timedout + unviable)) + echo "mutants examined: ${examined} — ${caught} caught, ${missed} survived, ${timedout} timed out, ${unviable} did not build" + if [ "$examined" -eq 0 ]; then + echo "::error::the run examined no mutant at all; the scope in .cargo/mutants.toml matches no source, and a scope that matches nothing is silent" >&2 + exit 1 + fi + if [ "$caught" -eq 0 ]; then + echo "::error::the run killed nothing; a mutation run that catches no mutant has not shown it can catch one, whatever it reports about the rest" >&2 + exit 1 + fi + echo "--- the finding: every mutant nothing caught ---" + cat "${report}/missed.txt" + echo "--- these are evidence debt, owed at the home of the source that carries them ---" + + # THE PLANTED REVERSAL. The step above establishes that the alarm worked + # today. It cannot establish that its own guard bites, because a guard that + # quietly stopped counting prints the same numbers as one that counted + # everything — and what it guards against costs nothing by MEASUREMENT: a + # scope matching no source finds no mutant, warns, writes empty rosters, + # and exits 0. + # + # So the same run is made deliberately wrong, by excluding every source the + # scope selects, and this step fails the job when that run comes back with + # a mutant. It writes to its own report directory so the finding above is + # not overwritten by a run that found nothing. MEASURED: it finishes in + # seconds, because a run with nothing to test builds no baseline. + # + # WHAT THIS COVERS: that an empty scope really is silent, and that a run + # over one is distinguishable from a run over the real one by the number + # this job counts. What it does NOT cover: the counting itself is written + # in the step above and read here from its own copy of the same + # expressions, so a change made to one and not the other is caught by + # review and by nothing else. That is the same ceiling + # `deny-reversal.toml` admits about the step that runs it, and it closes + # the same way — when a hosted run publishes the roster of what it + # executed. + - name: The planted reversal must examine nothing + run: | + set +e + cargo mutants --workspace -e 'xtask/**' --output target/reversal + code=$? + set -e + if [ "$code" -ne 0 ]; then + echo "::error::cargo-mutants exited ${code} against the planted empty scope; the reversal did not run, so it establishes nothing" >&2 + exit 1 + fi + report=target/reversal/mutants.out + examined=0 + for roster in caught missed timeout unviable; do + if [ -f "${report}/${roster}.txt" ]; then + examined=$((examined + $(wc -l < "${report}/${roster}.txt"))) + fi + done + if [ "$examined" -ne 0 ]; then + echo "::error::the planted empty scope examined ${examined} mutant(s); it is no longer wrong, so it proves nothing about the guard above" >&2 + exit 1 + fi + echo "the planted reversal examined nothing and exited 0: an empty scope is silent, which is why the run above counts" diff --git a/.github/workflows/qualify.yml b/.github/workflows/qualify.yml index 9c91740..a066752 100644 --- a/.github/workflows/qualify.yml +++ b/.github/workflows/qualify.yml @@ -33,10 +33,13 @@ on: # believing a setting. # # THE OPENING CONDITION, EXACTLY: someone attempts a direct push to `main` and - # records that it was refused. On that day this trigger comes out, and the - # matching one in `.github/workflows/dependencies.yml` comes out with it — they - # cover the same unexecuted refusal, and retiring one alone would leave the - # trunk covered on one side and bare on the other. + # records that it was refused. On that day this trigger comes out, and every + # matching trigger in the other workflows that carry one comes out with it — + # they cover the same unexecuted refusal, and retiring one alone would leave + # the trunk covered on one side and bare on the other. HOW MANY of them there + # are is deliberately not written here: a workflow that gains this cover would + # make a number in this comment wrong and nothing would say so. They are found + # by looking for the trigger, in `.github/workflows/`, where they live. push: branches: [main] # Weekly, on the trunk as it stands. diff --git a/Cargo.lock b/Cargo.lock index 4dadc45..c0fd9ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "blake3" version = "1.8.6" @@ -120,6 +126,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.47" @@ -136,6 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -313,6 +331,12 @@ dependencies = [ "toml", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -354,7 +378,11 @@ name = "xtask" version = "0.0.0" dependencies = [ "proc-macro2", + "pulldown-cmark", + "serde", + "serde_json", "syn", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ff931ed..b02448b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,17 +12,14 @@ workspace = true [workspace] resolver = "3" -# One line, and it is not a formatting preference. `no-core-tooling-edge` reads -# manifests a line at a time, so a value that does not close on the line that -# opens it hands the reader somebody else's data to read as structure — and a -# multi-line string, a multi-line list, or a multi-line inline table in a -# dependency table was measured hiding a prohibited edge from it on cargo -# 1.97.1. The law refuses that shape on sight rather than deciding case by case -# whether a particular body is dangerous, which is what deciding would cost: -# entering the value it has just said it cannot enter. This list is the one -# lawful casualty of that bluntness in the whole repository, and it pays it -# here rather than buying an exemption nobody could bound. -members = ["macros/macroc", "macros/proc", "testpak", "xtask", "xtask/fixtures/macro-consumer", "xtask/fixtures/renamed-consumer"] +members = [ + "macros/macroc", + "macros/proc", + "testpak", + "xtask", + "xtask/fixtures/macro-consumer", + "xtask/fixtures/renamed-consumer", +] [workspace.package] version = "0.0.0" @@ -55,7 +52,8 @@ repository = "https://github.com/freebatteryfactory/ThreadPak" # through under a key matching no package. That rule is deleted rather than # reworded: it asked every future author to protect a law from its own reader, # and a "never" in a comment is advice, not an invariant. The invariant is in -# `xtask/src/repository/manifest.rs`, with one planted reversal per spelling. +# `xtask/src/repository/cargo.rs`, where the decoder that owns TOML resolves the +# document and nine spellings of one declaration are proven to resolve alike. # # What this table declares is what the workspace ASKS FOR. What the resolved # graph actually enables is a different question, because Cargo unifies features @@ -77,14 +75,14 @@ repository = "https://github.com/freebatteryfactory/ThreadPak" # settlement is stated: a graph rule reads the graph, so a third fact — what any # one compiled unit is handed — is settled by neither file. syn = { version = "=3.0.3", default-features = false, features = ["full", "parsing"] } -# The token model `syn` parses FROM, named here because one law reads a -# `macro_rules!` transcriber — tokens that are not Rust until their -# metavariables have values. Giving them values means building a token stream, -# and a token stream has a type. Nothing new arrives in the graph: `syn` already -# holds this exact version, and `multiple-versions = "deny"` in `deny.toml` is -# what keeps that true. Exact for the same reason every pin here is exact. -# Default features off: the `proc-macro` bridge is the compiler's own token -# types, and no reader here runs inside a macro. +# The token model `syn` parses FROM, named here because one reading needs Rust's +# LEXICAL grammar rather than its item grammar: a crate-root reference is a path +# in a token stream, so `crate` has to be an identifier rather than a substring +# before a law can say whether a source reaches the root. Nothing new arrives in +# the graph — `syn` already holds this exact version, and `multiple-versions = +# "deny"` in `deny.toml` is what keeps that true. Exact for the same reason every +# pin here is exact. Default features off: the `proc-macro` bridge is the +# compiler's own token types, and no reader here runs inside a macro. proc-macro2 = { version = "=1.0.107", default-features = false } # The digest. Pinned exact because a projection identity is a value the plane # hands out, and a minor version that changed one byte of output would silently @@ -100,6 +98,31 @@ blake3 = { version = "=1.8.6", default-features = false } # the tree, so a harness free to move under a caret requirement could rewrite # what that comparison means without any change landing in the repository. trybuild = "=1.0.120" +# The TOML decoder, so the repository model reads Cargo's own syntax through the +# decoder that owns it rather than through a line reader that re-derives it. +# Eleven spellings escaped the hand reader this replaces — dotted keys, quoted +# keys, bracketed-string headers, escaped keys, literal-string values, +# multi-line strings, four-quote terminators, unicode escapes, inline tables, a +# quoted key carrying `=`, and a comment after a table header — and each repair +# revealed the next, because a spelling recognized one at a time is a set with no +# last member. Default features off, cut to the three the reading uses: `parse`, +# without which there is no text-to-document road; `serde`, which is how a +# document is obtained at all; and `std`. `display` is not asked for — this +# reader never writes TOML. +toml = { version = "=1.1.4", default-features = false, features = ["std", "serde", "parse"] } +# The Markdown decoder, so a fenced data block is selected by what the document +# STRUCTURALLY declares rather than by which fence happens to be written first. +# Default features off: `html` and `getopts` build a renderer and a command line, +# and this reader neither renders nor takes arguments. +pulldown-cmark = { version = "=0.13.4", default-features = false } +# The JSON decoder and the derive it deserializes through, for one input: +# `cargo metadata --format-version 1`. Everything Cargo RESOLVES — package +# identities, edge kinds, renames, target-conditioned edges — is answered by +# Cargo rather than re-derived from manifest text. The format version is pinned +# in the invocation because it is the machine-readable contract; the crates are +# pinned exact for the reason every pin here is exact. +serde = { version = "=1.0.229", default-features = false, features = ["std", "derive"] } +serde_json = { version = "=1.0.151", default-features = false, features = ["std"] } [profile.release] # Release arithmetic traps instead of wrapping; defense in depth behind the diff --git a/README.md b/README.md index d2d1bc2..b129ab1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,94 @@ restating it: one field, minted by `AdmittedLimit::under_profile`, so the comparison and its diagnostic have a single owner and the stronger witness cannot quietly stop being the stronger form of the weaker one. +**The same ladder stands twice, once per road a magnitude arrives by.** A +magnitude declared in the source is admitted by `AdmittedLimit` and proven +inhabited by `PositiveLimit`, both before the program runs. A magnitude the +owner's evidence SELECTS while the machine runs has no `L::MAX` for a `const` +block to read and no ceiling it could be compared against at the time such a +comparison would have to be settled, so its two facts are carried by values +instead: `LimitWitness` is what schema validation selected, and +`PositiveLimitWitness` is that selection proven to admit an item. A family says +which ladder its magnitude travels by declaring `EvidenceSelectedLimit`, and +that declaration is the mint's bound — a family that never made it has no road +to a runtime capacity at all. Several families in the machine said +"evidence-selected" in a doc comment beside their declaration and said it nowhere +a road could read; that sentence is now a fact the compiler carries. + +**One family, one capacity authority, by type identity.** Nothing stopped a +family from declaring BOTH ladders — two authorities over one fact, which is the +two-independently-supplied-halves shape in its purest form — and `crate::types` +said so in a doc comment while the type system permitted it. `Limit` now carries +`type Authority`, one of `DeclaredMagnitude`, `EvidenceSelectedMagnitude`, or +`UnstatedMagnitude`, and each ladder names the authority it requires exactly: +`ConstLimit: Limit` and +`EvidenceSelectedLimit: Limit`. An +associated type resolves to one type, so the second ladder is a type mismatch at +the declaration rather than a defect a reader has to notice — the exclusion is +the arity of an associated type and not a bound, a law, or a sentence. +`testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs` is +the executed reversal, and its recorded diagnostic names +`::Authority` as the projection that cannot be two +types. + +`UnstatedMagnitude` is the third state and the largest population in the crate: +a family bounding only a `Bounded` seat needs no magnitude, because +`Bounded::empty` reads none. It also makes a residue visible rather than absent — +families whose prose says "schema-witnessed" while their declaration says nothing +a road can read now say *that* in the type system, and moving one onto a ladder +is a change to one line at its own declaration. + +**What the algebra does not yet reach, stated where it is read.** `LimitWitness` +has only a crate-internal `#[cfg(test)]` mint, so production schema validation +can neither mint nor consume a runtime capacity: what stands is the algebra and +the declaration-side population guard, not a production road, and the opening +condition is the schema home carrying a validation path that selects a +magnitude. Beside it, the collection-shaped refusal mints on `AdmittedPrefix` are +bounded on `L: ConstLimit`, so a family on the runtime ladder — whose authority +is `EvidenceSelectedMagnitude` and therefore never `DeclaredMagnitude` — cannot +reach that package at all. Every collection-shaped body in the machine seats an +`AdmittedPrefix`, so `PositiveLimitWitness` is today a witness nothing among them +takes. Both absences are named at the types themselves; neither is closed here. + +With that rung in place, EVERY constructor of the inhabitant-promising shape +consumes evidence that its family admits an item: the two `const` roads prove it +off the declaration, `admitted_const` and `admitted_prefix` take `PositiveLimit`, +and `admitted` takes `PositiveLimitWitness`. The claim is total rather than +sampled because the shape's seats are private — no road into it can exist outside +`src/types.rs` and its guarded child. The other direction — "every limit family +whose seat promises an inhabitant declares its ladder" — is a POPULATION +question, which no law inside the crate can state because Rust cannot enumerate +the types implementing a trait. It is derived instead: +`cargo xtask check`'s `inhabitant-promising-limits-are-witnessed` reads the +machine's sources, takes every family that bounds a `NonEmptyBounded` or an +`AdmittedPrefix` seat while declaring no `ConstLimit`, and prints how many of +them are on the ladder over how many there are. A family that gains such a seat +without declaring `EvidenceSelectedLimit` is caught by that derivation rather +than by anybody remembering a list. + +That derivation keys a family by its TERMINAL name, and it has to: a seat spells +its bound `NonEmptyBounded`, with no home in the spelling and +no resolver in the reader to supply one. Two homes declaring one ordinary name +would therefore collapse into one record, folding one home's ladder onto the +other home's seat — the alias collision that already cost this repository a +module, reproduced inside the law that replaced part of it. So a terminal name +declared at more than one site now REFUSES and leaves the population, rather +than being merged or quietly qualified: the seat side carries no owner to +qualify against, and refusing loud is the honest move until a generated +declaration contract supplies an owner-qualified identity both sides carry. A +site is the file plus its inline module chain, so two `mod` blocks in one file +collide exactly as two files do. Measured on this tree, no collision exists +today, and that measurement is a test rather than a sentence. + +A drift detector stands over the other side +of the same fact — `rustc` answers an unsatisfied bound by listing the types +that satisfy it, so the recorded diagnostic in +`testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.stderr` +carries the ladder's roster derived from the impls, and a family joining or +leaving it fails that fixture. What the derivation does not reach is stated +where it is read: the services declare their families through a transcriber no +syntax reader expands, so the population is the machine's own. + Each construction road states its own claim class, and the classes do not substitute for one another: @@ -153,7 +241,7 @@ substitute for one another: | `NonEmptyBounded::admitted_const` | admitted family magnitude, and it must be inhabited | `PositiveLimit` | | `AdmittedPrefix::examined_completely` | admitted family magnitude, reported rather than refused | `PositiveLimit` | | `AdmittedPrefix::stopped_early` | admitted family magnitude, and it must be inhabited | `PositiveLimit` | -| `NonEmptyBounded::admitted` | schema-minted runtime magnitude | `LimitWitness` | +| `NonEmptyBounded::admitted` | schema-minted runtime magnitude, and it must be inhabited | `PositiveLimitWitness` | `examined_completely` is the one road that neither refuses nor claims completeness. Refusing is right for material that is meaningless in part — a @@ -279,6 +367,26 @@ obligations: challenge_kind: compile-refusal green: laws.rs root::positivity_is_the_stronger_witness red: testpak/tests/compile-fail/a-zero-maximum-family-cannot-mint-a-positive-limit.rs + - id: root.a-runtime-capacity-is-witnessed-positive + challenge_kind: compile-law + green: laws.rs root::a_runtime_capacity_is_witnessed_positive + red: owed-to-testpak — a zero capacity refuses rather than failing to + compile, so the reversal is a behavioral hostile and not a fixture; it is + executed on the refusing arm of the green law meanwhile, and driving it + from OUTSIDE the crate stays owed while `LimitWitness` has only its + `cfg(test)` mint + - id: root.a-capacity-witness-does-not-cross-families + challenge_kind: compile-refusal + green: laws.rs root::a_capacity_witness_does_not_cross_families + red: testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs + - id: root.the-runtime-ladder-is-declared-by-its-family + challenge_kind: compile-refusal + green: laws.rs root::the_runtime_ladder_is_declared_by_its_family + red: testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs + - id: root.a-family-declares-one-capacity-authority + challenge_kind: compile-refusal + green: laws.rs root::a_family_declares_one_capacity_authority + red: testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs - id: root.the-positive-witness-carries-the-admitted-one challenge_kind: compile-refusal green: laws.rs root::the_positive_witness_carries_the_admitted_one diff --git a/clippy.toml b/clippy.toml index fb58a06..54a68fd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -114,3 +114,84 @@ max-fn-params-bools = 0 # not a printed line, when a configured path rots. That one fixture is then both # the reversal and the staleness guard: a path that stops resolving stops # refusing, and the fixture goes red. +# +# ──────────────────────────────────────────────────────────────────────────── +# +# THE FIRST CONDITION IS DISCHARGED. The population was classified — every call +# of the four methods read at its own site, in the machine, the tooling and the +# judge — and what that found is below. The second condition is untouched by it, +# so the table is still not here. +# +# THE PLACING IS DECIDABLE RATHER THAN A MATTER OF TASTE, because the four kinds +# are TOTAL over the population and a site is placed by ELIMINATION: +# - it is not LAWFUL TOTAL if the call CAN fail in a way the fallback hides, +# however lawful the site reads — and a fallback whose value is exposed by an +# assertion on the next line hides nothing, which is what most of this +# population turns out to be; +# - it is not OPERATIONAL TOOLING unless the read is a host fact the tooling +# profile admits BY NAME. A repository file's CONTENT is not one. Where cargo +# is, where the root is, where a temporary directory is — those are the +# admitted facts, and a file's bytes are the SUBJECT a law judges rather than +# a fact about the host it judges from; +# - it is not DIAGNOSTIC RENDERING unless the substituted value reaches human +# text and stops there. +# What survives all three is SEMANTIC SILENT FALLBACK, and that is the only +# defect. Nothing is placed by how it looks. +# +# NO TOTAL IS WRITTEN HERE, per kind or over the population, and the omission is +# the same one the paragraphs above make for the same reason. What IS written is +# the first kind's sites, BY NAME, and a name is not a count: a reader checks a +# named site by opening the file it names, and there is nothing anybody can open +# to check a number. A named site that was repaired reads as wrong to the first +# person who looks; a stale total reads as fine forever. +# +# THE FIRST KIND, NAMED. Three sites, and each one is repaired at its own home +# by its own owner, because each repair is a type or a refusal and none of them +# is a lint: +# +# - `src/00_refusal/types.rs`, `DeclaredCauseOrder::ordinal_of`. A position +# past what `CauseOrdinal` represents becomes `None`, and `None` on that road +# MEANS "this order declares no such cause" — so an over-magnitude +# declaration answers a question about a cause it does declare with the word +# for absent. `declared` takes a bare static slice and proves nothing about +# its length, so the fit that type's own documentation calls provable is +# proven nowhere. Latent rather than live: no order in this tree is near the +# bound. The repair is an order that carries its admitted magnitude, so the +# conversion cannot fail at all. +# +# - `xtask/src/checks/vocabulary.rs`, `check_no_personal_names`. The file's +# bytes are decoded lossily and the decoding is what the law searches, so a +# file this reader cannot decode is judged as a rendering of itself and +# passes. What that entails, read off the substitution rather than executed: +# a forbidden spelling written in an encoding this decoder does not read +# survives as something else and is not found, and the law reports PASS over +# it. Nothing in this tree is such a file today, which is why the entailment +# is stated and not measured — measuring it means committing one. +# +# - `xtask/src/checks/vocabulary.rs`, `check_banned_vocabulary`. The same call +# in the same shape over the scanned tree, with the same consequence for the +# same reason. +# +# The repair for the two scanning laws is a standard this repository already +# applies one module over: the obligations join refuses a source it cannot parse +# because whether that source declares a test is UNKNOWN rather than false. A +# file that cannot be decoded is unknown in exactly that sense, and the honest +# answer is to name it, not to judge a substitute for it. +# +# THE THIRD KIND IS EMPTY, and that is a finding rather than an absence nobody +# looked for. Not one operational host read in this tree is spelled with any of +# the four methods — the ones that exist are `unwrap_or_else` with a named +# fallback beside them — so a `disallowed-methods` table would refuse nothing in +# that class. The environment paragraph above reduced the class to euphemism; +# this reduces it to nobody. +# +# WHY THE SECOND CONDITION IS STILL NOT MET, MEASURED rather than assumed. All +# four methods have lawful uses left after the three sites above are set aside, +# and most of the population is one shape: a fallback standing immediately +# beneath an assertion that refuses the fallback's own value, in the judge and in +# the laws' own controls. `disallowed-methods` has no crate scope and this +# repository refuses a production `#[expect]`, so a table installed now would +# refuse every one of those lawful sites with no lawful way to say so. The table +# stays out until the three are repaired at their homes and each method has zero +# lawful uses left — which is the condition already written above, unchanged by +# this pass. diff --git a/deny-reversal.toml b/deny-reversal.toml index 5a6e381..a7ab10c 100644 --- a/deny-reversal.toml +++ b/deny-reversal.toml @@ -1,5 +1,12 @@ -# The planted reversal for `deny.toml`, and the only configuration in this -# repository that is deliberately wrong. +# The planted reversal for `deny.toml`: a configuration that is deliberately +# wrong, kept on purpose. +# +# It used to say it was the ONLY one, and that clause is deleted rather than +# renumbered. It stopped being true the moment a second deliberately wrong +# configuration landed — `.config/nextest.toml`'s `reversal` profile — and a +# count of its own kind written in prose is the shape this repository refuses +# everywhere else. What it IS, is stated below; how many there are is not a fact +# this file has any way to keep true. # # `deny.toml` settles what the resolved graph is allowed to be, and the hosted # dependency workflow runs cargo-deny against it and prints `ok`. That run diff --git a/deny.toml b/deny.toml index 8e4d7ca..5c1e2c9 100644 --- a/deny.toml +++ b/deny.toml @@ -34,6 +34,19 @@ # on what a working machine happens to have. It runs beside the bar in the hosted # workflow instead, where the host is one nobody configured. # +# WHAT THIS FILE DOES NOT REACH, said out loud because the number of things in +# that position grew. This file settles the graph THIS WORKSPACE resolves. It +# says nothing about the graph a `cargo install` resolves, and three hosted jobs +# now install a tool that way — cargo-deny itself, cargo-nextest, and +# cargo-mutants. Each is pinned to an exact version with `--locked`, so what +# those builds resolve is decided rather than drifting, and each tool's own +# reason for existing is written beside its configuration. But no licence rule, +# no source rule, and no feature rule here is applied to any of them: they are +# build-time tools on a disposable runner, they ship in nothing, and this file's +# subject is what the machine is made of. That is a boundary NAMED rather than +# closed, and closing it would mean a second graph check over a second lock file +# that no runner writes down today. +# # A run of this file reports `ok`, and an `ok` is not evidence that anything here # still refuses. `deny-reversal.toml` sits beside this file carrying one # deliberately wrong rule; the same hosted workflow runs cargo-deny against it diff --git a/macros/macroc/README.md b/macros/macroc/README.md index 657fda6..0576319 100644 --- a/macros/macroc/README.md +++ b/macros/macroc/README.md @@ -241,8 +241,11 @@ tooling-obligation: macroc.a-refusal-body-seat-cannot-be-written-from-outside keeps a carry and its posture together; the private seat stops the record being written as a LITERAL, because a one-field record whose one field is public is a record any holder can spell. Each body is read back through one - borrowed reader and through nothing else. - owner: macros/macroc/src/refusal/types.rs + borrowed reader and through nothing else. The declaration sits in a `seat` + module inside its home's `type_guard.rs` rather than in `types.rs`, because + Rust's privacy is module-scoped and a seat declared beside dozens of other + types puts every one of them inside its wall. + owner: macros/macroc/src/refusal/type_guard.rs positive: macros/macroc/src/laws.rs method: compile-refusal activation: cargo test -p threadpak-testpak --test compile_refusals @@ -270,11 +273,11 @@ tooling-obligation: macroc.a-refusal-body-is-minted-only-inside-the-plane pass established, and lets any holder of the borrowed body clone its issues out and reseat them under a fresh record indistinguishable from one a seam returned. Each mint sits at the narrowest scope its own establishing passes - reach — module-private in `template`, `closure`, `explanation_protocol`, - `trigger_view` and `composition`, whose passes live in the same - `type_guard.rs`; home-scoped in `derive_refusal`, whose capture pass is a - sibling; and crate-scoped for the shared planning family, whose passes live in - four homes at once. + reach — `pub(super)` out of the `seat` module in `template`, `closure`, + `explanation_protocol`, `trigger_view` and `composition`, which reaches the + `type_guard.rs` their passes live in and stops there; home-scoped in + `derive_refusal`, whose capture pass is a sibling; and crate-scoped for the + shared planning family, whose passes live in four homes at once. owner: macros/macroc/src/refusal/type_guard.rs positive: macros/macroc/src/laws.rs method: compile-refusal @@ -284,33 +287,37 @@ tooling-obligation: macroc.a-refusal-body-is-minted-only-inside-the-plane refusing with E0624; restoring `pub` on any one of the ten roads makes that line resolve and fails the fixture nonclaims: > - The FIXTURE is not the universal statement and never could be: it names the ten - roads that exist today, so a family added later with a public mint would leave - it compiling and passing. What closes the population is a repository law - instead — `cargo xtask check`'s `refusal-mints-are-inside-the-plane`, which - DERIVES the population rather than naming it: every services record whose every - seat is private and that some road refuses with, joined against every road that - hands one back, with both denominators printed on every run. That law asks one - question — does this road hand a caller OWNERSHIP of a closed body — and it - RESOLVES it rather than matching a spelling: it walks the whole return type, so - a body inside a `Box`, a tuple, a collection or an opaque iterator is a body; it - resolves a type alias to what it stands for; it treats a borrow as access to a - body that already exists rather than a new one; and it excludes the error - position of a `Result` by decision, because a caller receiving the refusal a - seam raised is what the type is FOR. A road is a copy rather than a mint only - where its receiver IS the body it hands back. A return shape it cannot resolve - is REFUSED, never passed over. - What it does not establish is narrowed to exactly this. It reaches no module - chain: a road's reach is read off the declaration that states it — its own `pub` - for an inherent road, the implemented contract's for a trait road — so a `pub` - item in a module nobody re-exports reads as reachable. That direction refuses - loudly rather than passing silently, and the repair is one word. It expands no - macro, so a record or a road a macro assembles is outside it altogether, and it - evaluates no `cfg`. It reads a type by its last path segment, so two homes - declaring one name are one subject. And it does not claim exclusion inside each - mint's own scope: a crate-scoped mint is reachable by every module in the - services, and the module order `lib.rs` declares is what enumerates the seams - that use it. + The FIXTURE is not a universal statement over roads nobody has written yet: it + names the ten roads that exist today, so a family added later with a public + mint would leave it compiling and passing. A repository law used to stand + beside it and try to close that gap by DERIVING the population — every closed + record some road refuses with, joined against every road handing one back — + and it was wrong repeatedly, because answering "does this road hand a caller + ownership of a closed body" means resolving types, following aliases, deciding + what a receiver stands for, and inferring reachability from visibility and + module chains. It read a `Box`, a `Vec`, a tuple, an opaque iterator, a type + alias, a nested `Result`, a free function and an implementation for a + reference wrong in turn, and once refused a lawful road under a private + module-local trait. That law is deleted rather than taught a thirteenth shape. + What stands in its place is structural and much narrower. Each body is now + DECLARED in a `seat` module inside its home's `type_guard.rs`, whose entire + content is that record and inherent implementations of it, so the set of code + that can reach the private seat is a module a reader reads in one screen + rather than a file with dozens of types in it — and `cargo xtask check`'s + `seat-modules-carry-nothing-else` holds it to that by reading item kinds and + identifiers alone, resolving no type, following no alias and reading no + visibility. Everything outside the seat module is `rustc`'s refusal: `E0451` + on the literal, `E0616` on the field. + What that does NOT establish is stated rather than implied. It does not decide + which records must be seated: a closed record declared in a module named + anything else is outside the law's population, and the fixture beside it is + what names these seven. It does not read what a road hands back, so an + inherent road written INSIDE a seat module could still return the seat and no + check would say so — what the move buys is that every such road is in one + small module rather than scattered through a file, and that the module cannot + grow sideways. And it does not claim exclusion inside each mint's own scope: a + crate-scoped mint is reachable by every module in the services, and the module + order `lib.rs` declares is what enumerates the seams that use it. tooling-obligation: macroc.a-plan-watches-every-identity-it-hangs-off-or-refuses claim: > diff --git a/macros/macroc/src/closure/type_guard.rs b/macros/macroc/src/closure/type_guard.rs index d9220e6..d3a7664 100644 --- a/macros/macroc/src/closure/type_guard.rs +++ b/macros/macroc/src/closure/type_guard.rs @@ -13,24 +13,31 @@ //! workspace that can spell the literal, and every refusal that exists came off //! the per-role pass. //! +//! # Why the refusal body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped, so a seat declared in `types.rs` puts every +//! other item in that file inside the wall and leaves "did anybody write a road +//! out?" as a whole-file audit. The body is therefore declared in the `seat` +//! module below, whose entire content is that record and inherent +//! implementations of it — held to exactly that by `cargo xtask check`'s +//! `seat-modules-carry-nothing-else`. +//! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: `prove.rs` beside it, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS — a module declared -//! inside this one would construct as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes, -//! and the reversals for this seat are testpak's compile-fail fixtures instead. +//! It excludes every SIBLING: `types.rs` above it, `prove.rs` beside it, +//! anywhere else in the services, and any crate downstream cannot write the +//! literal, and the compiler says so with `E0451`. It does not exclude +//! DESCENDANTS — a module declared inside a guard would construct as freely as +//! these roads do, so the reversals for these seats are testpak's compile-fail +//! fixtures instead, and the law above refuses a nested module in a `seat` +//! module outright. use super::super::prove::examined; -use super::{ - ClosureIssue, ProjectionClosure, ProjectionClosureRefusal, RenderedProjection, RenderedUnit, - RenderingRefusal, -}; +use super::{ClosureIssue, ProjectionClosure, RenderedProjection, RenderedUnit, RenderingRefusal}; use crate::origin_graph::OriginTrail; use crate::plane::{ - AuthoringLimitProfile, ClosureId, ClosureIssueLimit, GeneratedUnitSubject, OutputBytesSubject, - PlanId, ProfileVersion, ProjectionIdentity, ProjectionProfileSubject, ProjectionProvenance, + AuthoringLimitProfile, ClosureId, GeneratedUnitSubject, OutputBytesSubject, PlanId, + ProfileVersion, ProjectionIdentity, ProjectionProfileSubject, ProjectionProvenance, ProjectionRole, ProjectionTranscript, RenderedRole, RenderedUnitSubject, encode_bytes, encode_length, }; @@ -39,7 +46,6 @@ use crate::planning::{ }; use crate::question::EXPLANATION_PROTOCOL_VERSION; use crate::token::GeneratedTree; -use threadpak::refusal::{AdmittedPrefix, StopBound}; use threadpak::types::{AdmittedLimit, Bounded, NonEmptyBounded, PositiveLimit}; /// The refusal one established issue list amounts to, or nothing where the list @@ -56,33 +62,71 @@ fn refused(issues: Vec>) -> Option ProjectionClosureRefusal { - /// The body a closure check refuses with. +pub use seat::ProjectionClosureRefusal; + +mod seat { + use super::super::ClosureIssue; + use crate::plane::{AuthoringLimitProfile, ClosureIssueLimit, RenderedRole}; + use threadpak::refusal::{AdmittedPrefix, StopBound}; + use threadpak::types::PositiveLimit; + + /// The closure refusal family body. /// - /// The per-role pass walks the kind's whole roster before a body exists, so - /// the posture here is about the REPORT rather than the pass. Where every - /// established issue fits the declared bound the body carries all of them; - /// where it does not, the body carries what the bound holds and names how - /// many established issues stand outside it — never a silent drop. - fn established(first: ClosureIssue, rest: Vec>) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), + /// Independent members: a rendering may drop one role and orphan another in + /// one pass, and reporting one of them would leave a caller repairing a + /// rendering one role per attempt. + #[must_use = "a refusal family body carries every way the rendering and the plan disagree"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct ProjectionClosureRefusal { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue the pass + /// established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. The pass itself always + /// covers every applicable role, so the completion here never reports a + /// halted examination. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one pass could write it into another + /// pass's refusal. Read back through [`ProjectionClosureRefusal::body`]. + body: AdmittedPrefix, ClosureIssueLimit>, + } + + impl ProjectionClosureRefusal { + /// The body a closure check refuses with. + /// + /// The per-role pass walks the kind's whole roster before a body exists, + /// so the posture here is about the REPORT rather than the pass. Where + /// every established issue fits the declared bound the body carries all + /// of them; where it does not, the body carries what the bound holds and + /// names how many established issues stand outside it — never a silent + /// drop. + /// + /// Reaches the guard file and no further — `pub(super)` from inside the + /// seat is exactly the module-private reach this road had before the + /// declaration moved, and the pass that raises it is beside it. + pub(super) fn established(first: ClosureIssue, rest: Vec>) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } } - } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix, ClosureIssueLimit> { - &self.body + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body(&self) -> &AdmittedPrefix, ClosureIssueLimit> { + &self.body + } } } diff --git a/macros/macroc/src/closure/types.rs b/macros/macroc/src/closure/types.rs index 1bfe605..d059855 100644 --- a/macros/macroc/src/closure/types.rs +++ b/macros/macroc/src/closure/types.rs @@ -9,13 +9,12 @@ use crate::origin_graph::OriginTrail; use crate::plane::{ - ClosureId, ClosureIssueLimit, GeneratedUnitSubject, MembershipLimit, OutputBytesSubject, - PlanId, ProfileVersion, ProjectionIdentity, ProjectionProfileSubject, ProjectionProvenance, - RenderedByteLimit, RenderedRole, RenderedUnitSubject, + ClosureId, GeneratedUnitSubject, MembershipLimit, OutputBytesSubject, PlanId, ProfileVersion, + ProjectionIdentity, ProjectionProfileSubject, ProjectionProvenance, RenderedByteLimit, + RenderedRole, RenderedUnitSubject, }; use crate::planning::{MemberDestination, PlannedMembership}; use crate::token::GeneratedTree; -use threadpak::refusal::AdmittedPrefix; use threadpak::types::{Bounded, NonEmptyBounded}; #[path = "type_guard.rs"] @@ -155,29 +154,13 @@ pub enum ClosureIssue { JoinedTreeUnbounded, } -/// The closure refusal family body. +/// The closure refusal family body, published from this file and DECLARED in +/// `type_guard.rs`'s `seat` module, beside the only roads that reach its seat. /// -/// Independent members: a rendering may drop one role and orphan another in one -/// pass, and reporting one of them would leave a caller repairing a rendering -/// one role per attempt. -#[must_use = "a refusal family body carries every way the rendering and the plan disagree"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ProjectionClosureRefusal { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue the pass established - /// or names how many stand outside that bound. One seat rather than two, - /// because a coverage claim seated beside its body is a claim that can be - /// swapped for another body's. The pass itself always covers every - /// applicable role, so the completion here never reports a halted - /// examination. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one pass could write it into another pass's refusal. Read back - /// through [`ProjectionClosureRefusal::body`]. - body: AdmittedPrefix, ClosureIssueLimit>, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::ProjectionClosureRefusal; /// The proof that what was rendered is what was planned. /// diff --git a/macros/macroc/src/composition/type_guard.rs b/macros/macroc/src/composition/type_guard.rs index 8c61a83..e49b86d 100644 --- a/macros/macroc/src/composition/type_guard.rs +++ b/macros/macroc/src/composition/type_guard.rs @@ -9,23 +9,32 @@ //! its seat is private, so this file is the only module in the workspace that //! can spell the literal, and every refusal that exists came off the scan. //! +//! # Why the body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped, so a seat declared in `types.rs` puts every +//! other item in that file inside the wall and leaves "did anybody write a road +//! out?" as a whole-file audit. The body is therefore declared in the `seat` +//! module below, whose entire content is that record and inherent +//! implementations of it — held to exactly that by `cargo xtask check`'s +//! `seat-modules-carry-nothing-else`. +//! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: `establish.rs` beside it, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS — a module declared -//! inside this one would construct as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes, -//! and the reversals for this seat are testpak's compile-fail fixtures instead. +//! It excludes every SIBLING: the rest of this file, `types.rs` above it, +//! `establish.rs` beside it, anywhere else in the services, and any crate +//! downstream cannot write the literal, and the compiler says so with `E0451`. +//! It does not exclude DESCENDANTS — a module declared inside the seat would +//! construct as freely as these roads do, which is why the reversals for this +//! seat are testpak's compile-fail fixtures and why the law above refuses a +//! nested module in a `seat` module outright. use super::super::establish::duplicate_issues; -use super::{ - CompositionRoot, CompositionRootDeclaration, CompositionRootIssue, DescriptorProvider, -}; -use crate::plane::{AuthoringLimitProfile, CompositionIssueLimit, DescriptorProviderLimit}; -use threadpak::refusal::{AdmittedPrefix, StopBound}; +use super::{CompositionRoot, CompositionRootIssue, DescriptorProvider}; +use crate::plane::{AuthoringLimitProfile, DescriptorProviderLimit}; use threadpak::types::{ConstLimit, NonEmptyBounded, PositiveLimit}; +pub use seat::CompositionRootDeclaration; + /// The refusal one established issue list amounts to, or nothing where the list /// is empty. fn refused(issues: Vec) -> Option { @@ -37,35 +46,74 @@ fn refused(issues: Vec) -> Option) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), - } + /// Independent members: several providers may be doubled in one declaration, + /// and reporting one of them would leave a caller repairing the root one + /// provider per attempt. + #[must_use = "a refusal family body carries every established issue with the root"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct CompositionRootDeclaration { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue the scan + /// established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. The scan itself always + /// covers every declared provider, so the completion here never reports + /// a halted examination. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one scan could write it into another + /// scan's refusal. Read back through [`CompositionRootDeclaration::body`]. + body: AdmittedPrefix, } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix { - &self.body + impl CompositionRootDeclaration { + /// The body a declaration check refuses with. + /// + /// The duplicate scan runs the declared set to the end before a body + /// exists, so the posture here is never about the scan: it is about the + /// REPORT. Where every established issue fits the declared bound the + /// body carries all of them and says `Complete`; where it does not, the + /// body carries what the bound holds and names how many established + /// issues stand outside it. A posture claiming the examination stopped + /// would say nobody looked past the bound, and somebody did. + /// + /// Reaches the guard file and no further — `pub(super)` from inside the + /// seat is exactly the module-private reach this road had before the + /// declaration moved, and the passes that raise it are the ones beside + /// it. + pub(super) fn established( + first: CompositionRootIssue, + rest: Vec, + ) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } + } + + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body(&self) -> &AdmittedPrefix { + &self.body + } } } diff --git a/macros/macroc/src/composition/types.rs b/macros/macroc/src/composition/types.rs index ec15e4f..402e176 100644 --- a/macros/macroc/src/composition/types.rs +++ b/macros/macroc/src/composition/types.rs @@ -8,10 +8,8 @@ //! reviewed. use crate::plane::{ - CompositionIssueLimit, DescriptorProviderLimit, DescriptorProviderSubject, OwnerFactRef, - OwnerIdentityRef, + DescriptorProviderLimit, DescriptorProviderSubject, OwnerFactRef, OwnerIdentityRef, }; -use threadpak::refusal::AdmittedPrefix; use threadpak::types::NonEmptyBounded; #[path = "type_guard.rs"] @@ -81,28 +79,14 @@ pub enum CompositionRootIssue { }, } -/// The composition-root declaration refusal family body. +/// The composition-root declaration refusal family body, published from this +/// file and DECLARED in `type_guard.rs`'s `seat` module, beside the only roads +/// that reach its seat. /// -/// Independent members: several providers may be doubled in one declaration, -/// and reporting one of them would leave a caller repairing the root one -/// provider per attempt. -#[must_use = "a refusal family body carries every established issue with the root"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct CompositionRootDeclaration { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue the scan established - /// or names how many stand outside that bound. One seat rather than two, - /// because a coverage claim seated beside its body is a claim that can be - /// swapped for another body's. The scan itself always covers every declared - /// provider, so the completion here never reports a halted examination. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one scan could write it into another scan's refusal. Read back - /// through [`CompositionRootDeclaration::body`]. - body: AdmittedPrefix, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::CompositionRootDeclaration; /// The one composition root: every provider that participates, named once. /// diff --git a/macros/macroc/src/derive_refusal/type_guard.rs b/macros/macroc/src/derive_refusal/type_guard.rs index c52cad5..ceba7f9 100644 --- a/macros/macroc/src/derive_refusal/type_guard.rs +++ b/macros/macroc/src/derive_refusal/type_guard.rs @@ -23,8 +23,8 @@ use super::{ CapturedCause, CauseOrderStanding, ClosedExpansion, CrateBinding, DEFAULT_CRATE_BINDING, - DerivedMembership, RefusalCompileContext, RefusalDerivationDraft, RefusalDeriveRefusal, - RefusalDeriveSurface, RefusalOwnerFacts, + DerivedMembership, RefusalCompileContext, RefusalDerivationDraft, RefusalDeriveSurface, + RefusalOwnerFacts, }; use crate::closure::{ProjectionClosure, RenderedProjection}; use crate::diagnostics::{ @@ -40,13 +40,11 @@ use crate::plane::{ use crate::planning::{ DeriveImplProjection, ProjectionDisposition, ProjectionPlan, RenderedImplementation, }; -use crate::token::{GeneratedTree, SpanHandle, SpanTable}; +use crate::token::{GeneratedTree, SpanTable}; use threadpak::evidence::CauseDisposition; use threadpak::refusal::FamilyShape; use threadpak::types::Bounded; -use super::RefusalDeriveCapture; - impl CrateBinding { /// The default binding: the machine under its own package name. #[must_use] @@ -169,34 +167,55 @@ impl RefusalDeriveSurface { } } -impl RefusalDeriveRefusal { - /// The established refusal at one token of the declared input. +pub use seat::RefusalDeriveRefusal; + +mod seat { + use super::super::RefusalDeriveCapture; + use crate::token::SpanHandle; + + /// One capture refusal: the established cause, and the token it sits at. /// - /// Reachable only from inside this home, which is where the capture pass - /// lives. Both seats are private, so no caller can write the literal; this - /// road is what a caller would reach for instead, and a cause word plus a - /// span handle are both values anybody can spell — so a public road here - /// would hand any holder of those two a refusal the capture pass never - /// established, at a token it never read. - pub(in crate::derive_refusal) const fn established( + /// Both seats are required. A refusal that could omit its token would send + /// the caller looking, and a refusal that could omit its cause would be a + /// complaint rather than an answer. + #[must_use = "a capture refusal carries the established cause and the offending token"] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct RefusalDeriveRefusal { cause: RefusalDeriveCapture, token: SpanHandle, - ) -> Self { - Self { cause, token } } - /// The established cause. - pub const fn cause(self) -> RefusalDeriveCapture { - self.cause - } + impl RefusalDeriveRefusal { + /// The established refusal at one token of the declared input. + /// + /// Reachable only from inside this home, which is where the capture pass + /// lives. Both seats are private, so no caller can write the literal; + /// this road is what a caller would reach for instead, and a cause word + /// plus a span handle are both values anybody can spell — so a public + /// road here would hand any holder of those two a refusal the capture + /// pass never established, at a token it never read. + pub(in crate::derive_refusal) const fn established( + cause: RefusalDeriveCapture, + token: SpanHandle, + ) -> Self { + Self { cause, token } + } - /// The token the observation sits at. The producer resolves it to the exact - /// compiler span; the services never do. - #[must_use] - pub const fn token(self) -> SpanHandle { - self.token + /// The established cause. + pub const fn cause(self) -> RefusalDeriveCapture { + self.cause + } + + /// The token the observation sits at. The producer resolves it to the + /// exact compiler span; the services never do. + #[must_use] + pub const fn token(self) -> SpanHandle { + self.token + } } +} +impl RefusalDeriveRefusal { /// The compiler-facing rendering: one line naming the cause and where it /// was established, in whatever coordinate role the producer speaks. /// @@ -210,8 +229,8 @@ impl RefusalDeriveRefusal { /// being handed a number that means nothing. #[must_use] pub fn compiler_message(self, spans: &SpanTable) -> String { - let described = self.cause.described(); - match spans.coordinate_of(self.token) { + let described = self.cause().described(); + match spans.coordinate_of(self.token()) { Ok(coordinate) => { let position = coordinate.position; format!( @@ -231,23 +250,23 @@ impl RefusalDeriveRefusal { /// identities it supplies them, and where none exists at this seam the /// diagnostic says so. This module mints none of them, because none of them /// is its to mint — the services classify what they OBSERVED - /// ([`RefusalDeriveCapture::observed`]) and never mint the machine's cause - /// commitment. + /// ([`RefusalDeriveCapture::observed`](super::RefusalDeriveCapture::observed)) + /// and never mint the machine's cause commitment. pub fn diagnosed(self, spans: &SpanTable, machine: MachineAnchoring) -> MacrocDiagnostic { let repairs = Bounded::from_array([RepairAction { declared_by: OwnerFactRef::named("refusal", "family-shapes-are-three-and-closed"), - description: self.cause.description(), + description: self.cause().description(), }]); MacrocDiagnostic { machine, - summary: self.cause.description(), + summary: self.cause().description(), phase: MacrocPhase::Capture, site: DiagnosticSite { - token: self.token, - coordinate: SiteCoordinate::answered(spans.coordinate_of(self.token)), + token: self.token(), + coordinate: SiteCoordinate::answered(spans.coordinate_of(self.token())), }, expected: expected_contract(), - observed: self.cause.observed(), + observed: self.cause().observed(), // The plane classifies what it observed and never elects the // machine's cause posture: narrowing is the machine's progress to // report, not the compiler plane's to assert. diff --git a/macros/macroc/src/derive_refusal/types.rs b/macros/macroc/src/derive_refusal/types.rs index 3ff3eb5..339ba4c 100644 --- a/macros/macroc/src/derive_refusal/types.rs +++ b/macros/macroc/src/derive_refusal/types.rs @@ -27,7 +27,7 @@ use crate::plane::{ use crate::planning::{ DeriveImplProjection, ProjectionDisposition, ProjectionPlan, RenderedImplementation, }; -use crate::token::{SpanHandle, SpanTable}; +use crate::token::SpanTable; use threadpak::refusal::{ CauseId, CauseOrderDeclaration, DeclaredCause, DeclaredCauseOrder, FamilyShape, LocalCauseKey, RefusalFamily, RefusalFamilyId, @@ -291,17 +291,14 @@ capture_causes! { "the declared input exceeds a declared magnitude"; } -/// One capture refusal: the established cause, and the token it sits at. +/// One capture refusal, published from this file and DECLARED in +/// `type_guard.rs`'s `seat` module, beside the only roads that reach its two +/// seats. /// -/// Both seats are required. A refusal that could omit its token would send the -/// caller looking, and a refusal that could omit its cause would be a complaint -/// rather than an answer. -#[must_use = "a capture refusal carries the established cause and the offending token"] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct RefusalDeriveRefusal { - cause: RefusalDeriveCapture, - token: SpanHandle, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::RefusalDeriveRefusal; // --------------------------------------------------------------------------- // The declared output set. diff --git a/macros/macroc/src/explanation_protocol/type_guard.rs b/macros/macroc/src/explanation_protocol/type_guard.rs index 3aa6857..3af866c 100644 --- a/macros/macroc/src/explanation_protocol/type_guard.rs +++ b/macros/macroc/src/explanation_protocol/type_guard.rs @@ -12,30 +12,35 @@ //! workspace that can spell the literal, and every refusal that exists came off //! the coverage pass. //! +//! # Why the body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped, so a seat declared in `types.rs` puts every +//! other item in that file inside the wall and leaves "did anybody write a road +//! out?" as a whole-file audit. The body is therefore declared in the `seat` +//! module below, whose entire content is that record and inherent +//! implementations of it — held to exactly that by `cargo xtask check`'s +//! `seat-modules-carry-nothing-else`. +//! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: `establish.rs` beside it, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS — a module declared -//! inside this one would construct as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes, -//! and the reversals for this seat are testpak's compile-fail fixtures instead. +//! It excludes every SIBLING: the rest of this file, `types.rs` above it, +//! `establish.rs` beside it, anywhere else in the services, and any crate +//! downstream cannot write the literal, and the compiler says so with `E0451`. +//! It does not exclude DESCENDANTS — a module declared inside the seat would +//! construct as freely as these roads do, which is why the reversals for this +//! seat are testpak's compile-fail fixtures and why the law above refuses a +//! nested module in a `seat` module outright. use super::super::establish::coverage_issues; use super::super::project::human_line; use super::{ - ExplanationAnswer, ExplanationCoverage, ExplanationCoverageIssue, ProjectionExplanation, - ProjectionExplanationView, -}; -use crate::plane::{ - AuthoringLimitProfile, ExplanationIssueLimit, ExplanationSeatLimit, HumanProjection, - HumanTextLimit, + ExplanationAnswer, ExplanationCoverageIssue, ProjectionExplanation, ProjectionExplanationView, }; +use crate::plane::{AuthoringLimitProfile, ExplanationSeatLimit, HumanProjection, HumanTextLimit}; use crate::planning::ProjectionKind; use crate::question::ExplanationQuestion; use core::marker::PhantomData; -use threadpak::refusal::{AdmittedPrefix, StopBound}; -use threadpak::types::{AdmittedLimit, Bounded, ConstLimit, PositiveLimit}; +use threadpak::types::{AdmittedLimit, Bounded, ConstLimit}; /// The refusal one established issue list amounts to, or nothing where the list /// is empty. @@ -52,34 +57,76 @@ fn refused(issues: Vec) -> Option )) } -impl ExplanationCoverage { - /// The body a coverage check refuses with. +pub use seat::ExplanationCoverage; + +mod seat { + use super::super::ExplanationCoverageIssue; + use crate::plane::{AuthoringLimitProfile, ExplanationIssueLimit}; + use threadpak::refusal::{AdmittedPrefix, StopBound}; + use threadpak::types::PositiveLimit; + + /// The explanation-coverage refusal family body. /// - /// The coverage pass walks the kind's whole applicable roster and then every - /// supplied answer before a body exists, so the posture here is about the - /// REPORT rather than the pass. Where every established issue fits the - /// declared bound the body carries all of them; where it does not, the body - /// carries what the bound holds and names how many established issues stand - /// outside it. - fn established(first: ExplanationCoverageIssue, rest: Vec) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), - } + /// Independent members: several questions may be unanswered while another is + /// doubled, and reporting one of them would leave a caller repairing the + /// view one question per attempt. + #[must_use = "a refusal family body carries every uncovered, doubled, or inadmissible question"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct ExplanationCoverage { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue the coverage pass + /// established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. The pass itself always + /// covers every applicable question, so the completion here never + /// reports a halted examination. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one pass could write it into another + /// pass's refusal. Read back through [`ExplanationCoverage::body`]. + body: AdmittedPrefix, } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix { - &self.body + impl ExplanationCoverage { + /// The body a coverage check refuses with. + /// + /// The coverage pass walks the kind's whole applicable roster and then + /// every supplied answer before a body exists, so the posture here is + /// about the REPORT rather than the pass. Where every established issue + /// fits the declared bound the body carries all of them; where it does + /// not, the body carries what the bound holds and names how many + /// established issues stand outside it. + /// + /// Reaches the guard file and no further — `pub(super)` from inside the + /// seat is exactly the module-private reach this road had before the + /// declaration moved, and the pass that raises it is beside it. + pub(super) fn established( + first: ExplanationCoverageIssue, + rest: Vec, + ) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } + } + + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body( + &self, + ) -> &AdmittedPrefix { + &self.body + } } } diff --git a/macros/macroc/src/explanation_protocol/types.rs b/macros/macroc/src/explanation_protocol/types.rs index f57d637..de7bcda 100644 --- a/macros/macroc/src/explanation_protocol/types.rs +++ b/macros/macroc/src/explanation_protocol/types.rs @@ -10,10 +10,10 @@ use crate::diagnostics::RepairAction; use crate::origin_graph::DecisionTrace; use crate::plane::{ - AssumptionLimit, ExplanationIssueLimit, ExplanationSeatLimit, GeneratedUnitSubject, - MembershipLimit, OutputBytesSubject, OwnerFactRef, OwnerIdentityRef, PatternInstanceSubject, - PatternSubject, ProfileVersion, ProjectionIdentity, ProjectionKindSubject, - ProjectionProfileSubject, RepairLimit, RuntimeTraceSubject, TraceEntryLimit, + AssumptionLimit, ExplanationSeatLimit, GeneratedUnitSubject, MembershipLimit, + OutputBytesSubject, OwnerFactRef, OwnerIdentityRef, PatternInstanceSubject, PatternSubject, + ProfileVersion, ProjectionIdentity, ProjectionKindSubject, ProjectionProfileSubject, + RepairLimit, RuntimeTraceSubject, TraceEntryLimit, }; use crate::planning::{ CauseAnchoring, GraphAnchoring, InvalidationSet, PlannedOutput, ProjectionDisposition, @@ -21,7 +21,6 @@ use crate::planning::{ }; use crate::question::ExplanationQuestion; use core::marker::PhantomData; -use threadpak::refusal::AdmittedPrefix; use threadpak::types::Bounded; #[path = "type_guard.rs"] @@ -155,29 +154,14 @@ pub enum ExplanationCoverageIssue { }, } -/// The explanation-coverage refusal family body. +/// The explanation-coverage refusal family body, published from this file and +/// DECLARED in `type_guard.rs`'s `seat` module, beside the only roads that reach +/// its seat. /// -/// Independent members: several questions may be unanswered while another is -/// doubled, and reporting one of them would leave a caller repairing the view -/// one question per attempt. -#[must_use = "a refusal family body carries every uncovered, doubled, or inadmissible question"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ExplanationCoverage { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue the coverage pass - /// established or names how many stand outside that bound. One seat rather - /// than two, because a coverage claim seated beside its body is a claim that - /// can be swapped for another body's. The pass itself always covers every - /// applicable question, so the completion here never reports a halted - /// examination. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one pass could write it into another pass's refusal. Read back - /// through [`ExplanationCoverage::body`]. - body: AdmittedPrefix, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::ExplanationCoverage; /// A complete explanation view over one kind's plans. /// diff --git a/macros/macroc/src/plane/types.rs b/macros/macroc/src/plane/types.rs index f8d5093..c43fd59 100644 --- a/macros/macroc/src/plane/types.rs +++ b/macros/macroc/src/plane/types.rs @@ -8,7 +8,7 @@ //! boundary. use core::marker::PhantomData; -use threadpak::types::{Bounded, ConstLimit, Limit, LimitAdmissionProfile}; +use threadpak::types::{Bounded, ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile}; #[path = "type_guard.rs"] mod guard; @@ -127,15 +127,24 @@ impl LimitAdmissionProfile for AuthoringLimitProfile { const MAX_DECLARED_LIMIT: usize = 1_048_576; } -/// Declares the plane's limit families: each is a `Limit` with a compile-time -/// maximum, so every bounded seat in the plane names which bound governs it. +/// Declares the plane's limit families: each is a `Limit` whose capacity +/// authority is a magnitude written here, so every bounded seat in the plane +/// names which bound governs it and no family in the plane can acquire a second +/// authority for the same capacity. +/// +/// The authority and the magnitude are emitted from ONE row, in one expansion, +/// so a family cannot be declared here on the compile-time ladder while wearing +/// another road's authority: the transcriber writes `DeclaredMagnitude` and +/// `ConstLimit` together or writes neither. macro_rules! limits { ($( $(#[$note:meta])* $name:ident = $max:expr ),+ $(,)?) => { $( $(#[$note])* #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct $name; - impl Limit for $name {} + impl Limit for $name { + type Authority = DeclaredMagnitude; + } impl ConstLimit for $name { const MAX: usize = $max; } diff --git a/macros/macroc/src/refusal/type_guard.rs b/macros/macroc/src/refusal/type_guard.rs index 51d4879..04b4b69 100644 --- a/macros/macroc/src/refusal/type_guard.rs +++ b/macros/macroc/src/refusal/type_guard.rs @@ -4,23 +4,40 @@ //! Declared inside `types.rs` as its own child, which is what makes the home's //! claim structural rather than remembered. Every seam in the plane that refuses //! while planning reaches one of these roads, so no seam invents a body of its -//! own shape — and none can, because the seat those roads fill is private and -//! this file is the only module that can name it. The one-issue road is total: -//! the declared bound admits an item by compile-time proof, so refusing never -//! needs an error road of its own. The co-establishing road is the one that can -//! overrun, and when it does the body carries what the declared bound holds and -//! names how many established issues stand outside it — it never silently drops -//! the remainder and never claims a completeness it does not have. +//! own shape — and none can, because the seat those roads fill is private to the +//! `seat` module below and nothing else in the workspace is inside it. The +//! one-issue road is total: the declared bound admits an item by compile-time +//! proof, so refusing never needs an error road of its own. The co-establishing +//! road is the one that can overrun, and when it does the body carries what the +//! declared bound holds and names how many established issues stand outside it — +//! it never silently drops the remainder and never claims a completeness it does +//! not have. +//! +//! # Why the body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped. A seat declared in `types.rs` is private to +//! `types.rs`, which means every one of the dozens of other items in that file +//! is inside the wall — and the only remaining question is whether anybody wrote +//! a road out among them. That is a whole-file audit, and a whole-file audit is +//! not a claim a reader can settle by reading. +//! +//! So the declaration sits in the `seat` module below, whose entire content is +//! the body and the roads to it. The set of roads that reach this seat is now +//! the module rather than the file, and `cargo xtask check`'s +//! `seat-modules-carry-nothing-else` is what holds the module to that: a `seat` +//! module carries its one record and inherent implementations of it, and nothing +//! else at all. //! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: a module beside `types.rs`, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS. A module declared -//! inside this one would construct the body as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes -//! — which is why the reversals for this seat are compile-fail fixtures owned by -//! testpak, outside the crate, where the exclusion is total. +//! It excludes every SIBLING: the rest of this file, `types.rs` above it, +//! anywhere else in the services, and any crate downstream cannot write the +//! literal, and the compiler says so with `E0451`. It does not exclude +//! DESCENDANTS. A module declared inside the seat would construct the body as +//! freely as these roads do — which is why the reversals for this seat are +//! compile-fail fixtures owned by testpak, outside the crate, where the +//! exclusion is total, and why the law above refuses a nested module in a `seat` +//! module outright. //! //! # A private seat with a public mint is a fence with a loading dock //! @@ -34,77 +51,111 @@ //! permissions and this home still grants one of them, now at both halves. //! //! `pub(crate)` is this family's strongest reachable scope and not a compromise -//! taken for convenience. The other five services families are each raised by a -//! pass living in the same `type_guard.rs`, so each of their mints is -//! module-private. This one is the plane's SHARED planning family — every seam -//! that refuses while planning returns it — so its establishing passes live in -//! `planning`, `origin_graph`, `pattern_stamp` and this home at once, and the -//! narrowest scope that reaches all of them is the crate. What remains open is -//! stated rather than implied: inside the services, any module can still mint, -//! and the module order `lib.rs` declares is what enumerates the seams that do. +//! taken for convenience. The other five collection families are each raised by +//! a pass living in the same `type_guard.rs`, so each of their mints reaches no +//! further than that file. This one is the plane's SHARED planning family — +//! every seam that refuses while planning returns it — so its establishing +//! passes live in `planning`, `origin_graph`, `pattern_stamp` and this home at +//! once, and the narrowest scope that reaches all of them is the crate. What +//! remains open is stated rather than implied: inside the services, any module +//! can still mint, and the module order `lib.rs` declares is what enumerates the +//! seams that do. + +pub use seat::ProjectionPlanning; -use super::{BoundAxis, ProjectionPlanning, ProjectionPlanningIssue}; -use crate::plane::{AuthoringLimitProfile, PlanningIssueLimit}; -use threadpak::refusal::{AdmittedPrefix, StopBound}; -use threadpak::types::PositiveLimit; +mod seat { + use super::super::{BoundAxis, ProjectionPlanningIssue}; + use crate::plane::{AuthoringLimitProfile, PlanningIssueLimit}; + use threadpak::refusal::{AdmittedPrefix, StopBound}; + use threadpak::types::PositiveLimit; -impl ProjectionPlanning { - /// The one-issue body, for a seam whose checks can establish exactly one - /// issue. Total: the declared bound admits an item by compile-time proof, so - /// refusing never needs an error road of its own. + /// The planning refusal family body. /// - /// Crate-internal: a body exists only where a planning seam established the - /// issue it carries. - pub(crate) fn established(issue: ProjectionPlanningIssue) -> Self { - Self { - body: AdmittedPrefix::carrying_one(issue), - } + /// Independent members, no ladder, no primary issue, posture carried as an + /// instance value. A body that stopped at its declared bound says so rather + /// than implying no further defects exist. + #[must_use = "a refusal family body carries every planning issue the pass established"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct ProjectionPlanning { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue its seam + /// established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one seam could write it into another + /// seam's refusal. Read back through [`ProjectionPlanning::body`]. + /// + /// The seat closes the literal and the mints close the road beside it. A + /// private seat reached by a public generic constructor is a fence with a + /// loading dock behind it: a caller holding an issue mints a refusal no + /// pass raised, and a caller holding this borrow clones the issues out + /// and seats them under a fresh one. Both roads are crate-internal for + /// that reason, and the reason is stated where they are declared. + body: AdmittedPrefix, } - /// The several-issue body, for a pass whose checks co-establish. - /// - /// The caller arrives holding every issue its pass established, so the - /// posture this road writes is about the REPORT and never about the pass: - /// where the issues fit the declared bound the body carries all of them, and - /// where they do not the body carries what the bound holds and names how - /// many established issues stand outside it. It never silently drops the - /// remainder and never claims a completeness it does not have. - /// - /// Crate-internal, on the same terms as the one-issue road. - pub(crate) fn co_established( - first: ProjectionPlanningIssue, - rest: Vec, - ) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), + impl ProjectionPlanning { + /// The one-issue body, for a seam whose checks can establish exactly one + /// issue. Total: the declared bound admits an item by compile-time + /// proof, so refusing never needs an error road of its own. + /// + /// Crate-internal: a body exists only where a planning seam established + /// the issue it carries. + pub(crate) fn established(issue: ProjectionPlanningIssue) -> Self { + Self { + body: AdmittedPrefix::carrying_one(issue), + } } - } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix { - &self.body - } + /// The several-issue body, for a pass whose checks co-establish. + /// + /// The caller arrives holding every issue its pass established, so the + /// posture this road writes is about the REPORT and never about the + /// pass: where the issues fit the declared bound the body carries all of + /// them, and where they do not the body carries what the bound holds and + /// names how many established issues stand outside it. It never silently + /// drops the remainder and never claims a completeness it does not have. + /// + /// Crate-internal, on the same terms as the one-issue road. + pub(crate) fn co_established( + first: ProjectionPlanningIssue, + rest: Vec, + ) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } + } - /// The body a bounded seam refuses with: the axis it overran, the magnitude - /// it declared, and the count it observed. - /// - /// Crate-internal: it is the one-issue road under a spelling, and a spelling - /// of a closed road is not an opening of it. - pub(crate) fn bound_exceeded(axis: BoundAxis, bound: usize, observed: usize) -> Self { - Self::established(ProjectionPlanningIssue::BoundExceeded { - axis, - bound: u64::try_from(bound).unwrap_or(u64::MAX), - observed: u64::try_from(observed).unwrap_or(u64::MAX), - }) + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body(&self) -> &AdmittedPrefix { + &self.body + } + + /// The body a bounded seam refuses with: the axis it overran, the + /// magnitude it declared, and the count it observed. + /// + /// Crate-internal: it is the one-issue road under a spelling, and a + /// spelling of a closed road is not an opening of it. + pub(crate) fn bound_exceeded(axis: BoundAxis, bound: usize, observed: usize) -> Self { + Self::established(ProjectionPlanningIssue::BoundExceeded { + axis, + bound: u64::try_from(bound).unwrap_or(u64::MAX), + observed: u64::try_from(observed).unwrap_or(u64::MAX), + }) + } } } diff --git a/macros/macroc/src/refusal/types.rs b/macros/macroc/src/refusal/types.rs index d383c14..a6c5622 100644 --- a/macros/macroc/src/refusal/types.rs +++ b/macros/macroc/src/refusal/types.rs @@ -2,19 +2,20 @@ //! seats a fact can be missing from, the closed planning issue set, and the //! family body they travel in. //! -//! Declarations only. The body's one seat is private and its roads live in -//! `type_guard.rs`, this file's own child. Readable is not the same as writable: -//! a refusal body whose issues a caller could not read would be a refusal nobody -//! can act on, so the seat is read back through a borrow — and a refusal a caller -//! could WRITE would be a seam minting the plane's own answer, so there is no -//! literal anybody outside the nucleus can spell and no mint anybody outside the -//! crate can call. +//! Declarations only. The body itself is DECLARED in `type_guard.rs`'s `seat` +//! module — this file's own grandchild — and published from here, because Rust's +//! privacy is module-scoped and a seat declared in this file would be inside the +//! wall with every other item this file declares. Readable is not the same as +//! writable: a refusal body whose issues a caller could not read would be a +//! refusal nobody can act on, so the seat is read back through a borrow — and a +//! refusal a caller could WRITE would be a seam minting the plane's own answer, +//! so there is no literal anybody outside the seat module can spell and no mint +//! anybody outside the crate can call. use crate::plane::{ - GeneratedUnitSubject, OwnerFactRef, PlanningIssueLimit, ProfileVersion, ProjectionIdentity, - ProjectionKindSubject, ProjectionProfileSubject, + GeneratedUnitSubject, OwnerFactRef, ProfileVersion, ProjectionIdentity, ProjectionKindSubject, + ProjectionProfileSubject, }; -use threadpak::refusal::AdmittedPrefix; #[path = "type_guard.rs"] mod guard; @@ -173,31 +174,12 @@ pub enum ProjectionPlanningIssue { }, } -/// The planning refusal family body. +/// The planning refusal family body, published from this file and DECLARED in +/// `type_guard.rs`'s `seat` module, beside the only roads that reach its seat. /// -/// Independent members, no ladder, no primary issue, posture carried as an -/// instance value. A body that stopped at its declared bound says so rather -/// than implying no further defects exist. -#[must_use = "a refusal family body carries every planning issue the pass established"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ProjectionPlanning { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue its seam established - /// or names how many stand outside that bound. One seat rather than two, - /// because a coverage claim seated beside its body is a claim that can be - /// swapped for another body's. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one seam could write it into another seam's refusal. Read back - /// through [`ProjectionPlanning::body`]. - /// - /// The seat closes the literal and the mints close the road beside it. A - /// private seat reached by a public generic constructor is a fence with a - /// loading dock behind it: a caller holding an issue mints a refusal no pass - /// raised, and a caller holding this borrow clones the issues out and seats - /// them under a fresh one. Both roads are crate-internal for that reason, - /// and the reason is stated where they are declared. - body: AdmittedPrefix, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares dozens of other items that would each have been inside that +/// wall. The complete set of roads to the seat has to be readable, and a file is +/// too big a unit to read it off. +pub use guard::ProjectionPlanning; diff --git a/macros/macroc/src/template/type_guard.rs b/macros/macroc/src/template/type_guard.rs index f30b06a..39ce75d 100644 --- a/macros/macroc/src/template/type_guard.rs +++ b/macros/macroc/src/template/type_guard.rs @@ -11,34 +11,44 @@ //! only module in the workspace that can spell the literal. There is no other //! seam in the crate that can produce any of them. //! +//! # Why the refusal body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped, so a seat declared in `types.rs` puts every +//! other item in that file inside the wall and leaves "did anybody write a road +//! out?" as a whole-file audit. The body is therefore declared in the `seat` +//! module below, whose entire content is that record and inherent +//! implementations of it — held to exactly that by `cargo xtask check`'s +//! `seat-modules-carry-nothing-else`. +//! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: `establish.rs` beside it, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS — a module declared -//! inside this one would construct as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes, -//! and the reversals for this seat are testpak's compile-fail fixtures instead. +//! It excludes every SIBLING: `types.rs` above it, `establish.rs` beside it, +//! anywhere else in the services, and any crate downstream cannot write the +//! literal, and the compiler says so with `E0451`. It does not exclude +//! DESCENDANTS — a module declared inside a guard would construct as freely as +//! these roads do, so the reversals for these seats are testpak's compile-fail +//! fixtures instead, and the law above refuses a nested module in a `seat` +//! module outright. //! -//! And it excludes the literal only. The refusal body's two mints are -//! module-private for the other half of the same claim: a private seat reached -//! by a public generic constructor lets any holder of an issue produce a body no -//! pass established, and lets a holder of the borrowed body clone its issues out -//! and reseat them. Both roads sit beside the three passes that raise them. +//! And it excludes the literal only. The refusal body's two mints reach this +//! file and no further, for the other half of the same claim: a private seat +//! reached by a public generic constructor lets any holder of an issue produce a +//! body no pass established, and lets a holder of the borrowed body clone its +//! issues out and reseat them. Both roads sit beside the three passes that raise +//! them. use super::super::establish::{binding_issues, ceiling_issues, parameter_issues}; use super::{ ApplicativeDistinctness, AxisCeiling, CheckedMeterPosture, DeclarationTemplate, ProfileCeiling, SpliceCategory, SymbolicBoundFormula, TemplateApplication, TemplateArgument, TemplateBinding, - TemplateBindingIssue, TemplateConstruction, TemplateConstructionIssue, TemplateParameter, - TemplateSeat, VersionedProfile, + TemplateBindingIssue, TemplateConstructionIssue, TemplateParameter, TemplateSeat, + VersionedProfile, }; use crate::plane::{ AuthoringLimitProfile, LanguageProfileSubject, MetaBoundAxisLimit, MetaProfileSubject, - OwnerIdentityRef, TemplateIssueLimit, TemplateParameterLimit, TemplateSubject, + OwnerIdentityRef, TemplateParameterLimit, TemplateSubject, }; use threadpak::declaration::Stage; -use threadpak::refusal::{AdmittedPrefix, StopBound}; use threadpak::types::{AdmittedLimit, Bounded, ConstLimit, NonEmptyBounded, PositiveLimit}; /// The refusal one established issue list amounts to, or nothing where the list @@ -52,55 +62,90 @@ fn refused(issues: Vec) -> Option Self { - Self { - body: AdmittedPrefix::carrying_one(issue), - } + /// Independent members: a template may double one parameter while leaving + /// another category-disagreeing, and an application may leave one hole + /// unbound while binding an unknown one. No primary issue is elected, and a + /// zero-issue refusal is unrepresentable. + #[must_use = "a refusal family body carries every established issue with the template"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct TemplateConstruction { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue the three passes + /// established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. The passes themselves + /// always run their rosters to the end, so the completion here never + /// reports a halted examination. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one pass could write it into another + /// pass's refusal. Read back through [`TemplateConstruction::body`]. + body: AdmittedPrefix, } - /// The several-issue body. - /// - /// The three passes in `establish.rs` run their rosters to the end before a - /// body exists, so the posture here is about the REPORT and never about the - /// passes. Where every established issue fits the declared bound the body - /// carries all of them; where it does not, the body carries what the bound - /// holds and names how many established issues stand outside it — never a - /// silent drop, never an unearned claim of completeness, and never a claim - /// that nobody looked. - /// - /// Module-private, on the same terms as the one-issue road. - fn co_established( - first: TemplateConstructionIssue, - rest: Vec, - ) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), + impl TemplateConstruction { + /// The one-issue body. Total: the declared bound admits an item by + /// compile-time proof, so refusing never needs an error road of its own. + /// + /// Reaches the guard file and no further: the three passes that + /// establish these issues are the three checked constructors beside it, + /// and a body exists only where one of them ran. A public road here + /// would let any holder of an issue mint a refusal no pass raised, which + /// is the same opening a public SEAT would be — the seat and the mint + /// are two halves of one claim, and closing one of them closes neither. + pub(super) fn established(issue: TemplateConstructionIssue) -> Self { + Self { + body: AdmittedPrefix::carrying_one(issue), + } } - } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix { - &self.body + /// The several-issue body. + /// + /// The three passes in `establish.rs` run their rosters to the end + /// before a body exists, so the posture here is about the REPORT and + /// never about the passes. Where every established issue fits the + /// declared bound the body carries all of them; where it does not, the + /// body carries what the bound holds and names how many established + /// issues stand outside it — never a silent drop, never an unearned + /// claim of completeness, and never a claim that nobody looked. + /// + /// Reaches the guard file and no further, on the same terms as the + /// one-issue road. + pub(super) fn co_established( + first: TemplateConstructionIssue, + rest: Vec, + ) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } + } + + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body(&self) -> &AdmittedPrefix { + &self.body + } } } diff --git a/macros/macroc/src/template/types.rs b/macros/macroc/src/template/types.rs index ce2a67e..03bbf4e 100644 --- a/macros/macroc/src/template/types.rs +++ b/macros/macroc/src/template/types.rs @@ -13,12 +13,10 @@ use crate::plane::{ ApplicationDistinctnessSubject, BoundFormulaSubject, FragmentDependencyLimit, InputDescriptorLimit, InputDescriptorSubject, LanguageProfileSubject, MetaBoundAxisLimit, MetaProfileSubject, OwnerFactRef, OwnerIdentityRef, ProfileVersion, SourceSnapshotSubject, - TemplateArgumentSubject, TemplateIssueLimit, TemplateParameterLimit, TemplateParameterSubject, - TemplateSubject, + TemplateArgumentSubject, TemplateParameterLimit, TemplateParameterSubject, TemplateSubject, }; use threadpak::declaration::Stage; use threadpak::declaration::types::{FragmentIdentityDomain, ProjectionConfigurationDomain}; -use threadpak::refusal::AdmittedPrefix; use threadpak::types::{Bounded, NonEmptyBounded}; #[path = "type_guard.rs"] @@ -297,30 +295,14 @@ pub enum TemplateConstructionIssue { }, } -/// The template-construction refusal family body. +/// The template-construction refusal family body, published from this file and +/// DECLARED in `type_guard.rs`'s `seat` module, beside the only roads that reach +/// its seat. /// -/// Independent members: a template may double one parameter while leaving -/// another category-disagreeing, and an application may leave one hole unbound -/// while binding an unknown one. No primary issue is elected, and a zero-issue -/// refusal is unrepresentable. -#[must_use = "a refusal family body carries every established issue with the template"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct TemplateConstruction { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue the three passes - /// established or names how many stand outside that bound. One seat rather - /// than two, because a coverage claim seated beside its body is a claim that - /// can be swapped for another body's. The passes themselves always run their - /// rosters to the end, so the completion here never reports a halted - /// examination. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one pass could write it into another pass's refusal. Read back - /// through [`TemplateConstruction::body`]. - body: AdmittedPrefix, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::TemplateConstruction; /// One authored declaration template: its identity, its typed holes, the three /// locks it declares before any evaluation, and the stage its owner declared it diff --git a/macros/macroc/src/trigger_view/type_guard.rs b/macros/macroc/src/trigger_view/type_guard.rs index 68a3bf5..8380bde 100644 --- a/macros/macroc/src/trigger_view/type_guard.rs +++ b/macros/macroc/src/trigger_view/type_guard.rs @@ -10,22 +10,29 @@ //! in the workspace that can spell the literal, and every refusal that exists //! came off the disposition pass. //! +//! # Why the body is DECLARED here and not in `types.rs` +//! +//! Rust's privacy is MODULE-scoped, so a seat declared in `types.rs` puts every +//! other item in that file inside the wall and leaves "did anybody write a road +//! out?" as a whole-file audit. The body is therefore declared in the `seat` +//! module below, whose entire content is that record and inherent +//! implementations of it — held to exactly that by `cargo xtask check`'s +//! `seat-modules-carry-nothing-else`. +//! //! # What a private seat does and does not exclude //! -//! It excludes every SIBLING: `establish.rs` beside it, anywhere else in the -//! services, and any crate downstream cannot write the literal, and the compiler -//! says so with `E0451`. It does not exclude DESCENDANTS — a module declared -//! inside this one would construct as freely as these roads do, so a -//! `#[cfg(test)] mod` under the guard would reopen exactly what the guard closes, -//! and the reversals for this seat are testpak's compile-fail fixtures instead. +//! It excludes every SIBLING: the rest of this file, `types.rs` above it, +//! `establish.rs` beside it, anywhere else in the services, and any crate +//! downstream cannot write the literal, and the compiler says so with `E0451`. +//! It does not exclude DESCENDANTS — a module declared inside the seat would +//! construct as freely as these roads do, which is why the reversals for this +//! seat are testpak's compile-fail fixtures and why the law above refuses a +//! nested module in a `seat` module outright. use super::super::establish::disposition_issues; -use super::{ - TriggerOmission, TriggerSelection, TriggerViewComposition, TriggerViewIssue, WrapperTriggerView, -}; -use crate::plane::{AuthoringLimitProfile, PlanId, TriggerViewIssueLimit, WrapperComponentLimit}; -use threadpak::refusal::{AdmittedPrefix, StopBound}; -use threadpak::types::{AdmittedLimit, Bounded, ConstLimit, PositiveLimit}; +use super::{TriggerOmission, TriggerSelection, TriggerViewIssue, WrapperTriggerView}; +use crate::plane::{AuthoringLimitProfile, PlanId, WrapperComponentLimit}; +use threadpak::types::{AdmittedLimit, Bounded, ConstLimit}; /// The refusal one established issue list amounts to, or nothing where the list /// is empty. @@ -38,33 +45,70 @@ fn refused(issues: Vec) -> Option { )) } -impl TriggerViewComposition { - /// The body a composition check refuses with. +pub use seat::TriggerViewComposition; + +mod seat { + use super::super::TriggerViewIssue; + use crate::plane::{AuthoringLimitProfile, TriggerViewIssueLimit}; + use threadpak::refusal::{AdmittedPrefix, StopBound}; + use threadpak::types::PositiveLimit; + + /// The trigger-view composition refusal family body. /// - /// The disposition pass walks the whole component roster before a body - /// exists, so the posture here is about the REPORT rather than the pass. - /// Where every established issue fits the declared bound the body carries - /// all of them; where it does not, the body carries what the bound holds and - /// names how many established issues stand outside it. - fn established(first: TriggerViewIssue, rest: Vec) -> Self { - Self { - body: AdmittedPrefix::examined_completely( - first, - rest, - &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), - StopBound::DeclaredIssueBound, - ), - } + /// Independent members: several components may be undecided while another is + /// doubled, and a caller repairing a view one component per attempt is a + /// caller this seam failed. + #[must_use = "a refusal family body carries every undisposed or doubled component"] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct TriggerViewComposition { + /// The established issues — at least one, at most the declared bound — + /// together with whether the body carries every issue the disposition + /// pass established or names how many stand outside that bound. One seat + /// rather than two, because a coverage claim seated beside its body is a + /// claim that can be swapped for another body's. The pass itself always + /// covers every component, so the completion here never reports a halted + /// examination. + /// + /// Private, and that is the second half of the same claim. The coupled + /// seat keeps a carry and its posture together; a PUBLIC seat on a + /// one-field record hands the whole record back as a literal, so any + /// holder of a body built for one pass could write it into another + /// pass's refusal. Read back through [`TriggerViewComposition::body`]. + body: AdmittedPrefix, } - /// The established issues and what this refusal says about its own coverage - /// of them. - /// - /// Borrowed and never owned, for the reason band 00 borrows its carry: an - /// owned body is a value a caller can seat under another refusal, which is - /// the pairing the coupled seat exists to end. - pub const fn body(&self) -> &AdmittedPrefix { - &self.body + impl TriggerViewComposition { + /// The body a composition check refuses with. + /// + /// The disposition pass walks the whole component roster before a body + /// exists, so the posture here is about the REPORT rather than the pass. + /// Where every established issue fits the declared bound the body + /// carries all of them; where it does not, the body carries what the + /// bound holds and names how many established issues stand outside it. + /// + /// Reaches the guard file and no further — `pub(super)` from inside the + /// seat is exactly the module-private reach this road had before the + /// declaration moved, and the pass that raises it is beside it. + pub(super) fn established(first: TriggerViewIssue, rest: Vec) -> Self { + Self { + body: AdmittedPrefix::examined_completely( + first, + rest, + &PositiveLimit::<_, AuthoringLimitProfile>::inhabited_under_profile(), + StopBound::DeclaredIssueBound, + ), + } + } + + /// The established issues and what this refusal says about its own + /// coverage of them. + /// + /// Borrowed and never owned, for the reason band 00 borrows its carry: + /// an owned body is a value a caller can seat under another refusal, + /// which is the pairing the coupled seat exists to end. + pub const fn body(&self) -> &AdmittedPrefix { + &self.body + } } } diff --git a/macros/macroc/src/trigger_view/types.rs b/macros/macroc/src/trigger_view/types.rs index fdf3c02..2c59581 100644 --- a/macros/macroc/src/trigger_view/types.rs +++ b/macros/macroc/src/trigger_view/types.rs @@ -6,11 +6,8 @@ //! `type_guard.rs`, this file's own child, which is what makes exhaustive //! disposition structural. -use crate::plane::{ - OwnerFactRef, PlanId, SelectionCitationLimit, TriggerViewIssueLimit, WrapperComponentLimit, -}; +use crate::plane::{OwnerFactRef, PlanId, SelectionCitationLimit, WrapperComponentLimit}; use crate::planning::WrapperComponent; -use threadpak::refusal::AdmittedPrefix; use threadpak::types::{Bounded, NonEmptyBounded}; #[path = "type_guard.rs"] @@ -75,28 +72,14 @@ pub enum TriggerViewIssue { }, } -/// The trigger-view composition refusal family body. +/// The trigger-view composition refusal family body, published from this file +/// and DECLARED in `type_guard.rs`'s `seat` module, beside the only roads that +/// reach its seat. /// -/// Independent members: several components may be undecided while another is -/// doubled, and a caller repairing a view one component per attempt is a caller -/// this seam failed. -#[must_use = "a refusal family body carries every undisposed or doubled component"] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct TriggerViewComposition { - /// The established issues — at least one, at most the declared bound — - /// together with whether the body carries every issue the disposition pass - /// established or names how many stand outside that bound. One seat rather - /// than two, because a coverage claim seated beside its body is a claim that - /// can be swapped for another body's. The pass itself always covers every - /// component, so the completion here never reports a halted examination. - /// - /// Private, and that is the second half of the same claim. The coupled seat - /// keeps a carry and its posture together; a PUBLIC seat on a one-field - /// record hands the whole record back as a literal, so any holder of a body - /// built for one pass could write it into another pass's refusal. Read back - /// through [`TriggerViewComposition::body`]. - body: AdmittedPrefix, -} +/// The declaration is not here because Rust's privacy is MODULE-scoped: a +/// private field is private to the module the declaration lands in, and this +/// file declares much else that would have been inside that wall. +pub use guard::TriggerViewComposition; /// The complete wrapper-trigger view over one plan. /// diff --git a/src/00_refusal/types.rs b/src/00_refusal/types.rs index 2abf32a..a8e8649 100644 --- a/src/00_refusal/types.rs +++ b/src/00_refusal/types.rs @@ -574,6 +574,25 @@ impl CauseOrderDeclaration for NonEmptyBoundedConstruction { )]); } +impl RefusalFamily for crate::types::CapacityAdmission { + const SHAPE: FamilyShape = FamilyShape::SingleCause; + const SELECTION_ORDER: &'static [&'static str] = &["NotInhabited"]; +} + +/// The third root construction family, and the one whose single cause is a +/// requirement rather than a bound: `NotInhabited` spells a failed requirement +/// with the `Not` prefix, where its two neighbours spell a bounds condition. +impl CauseOrderDeclaration for crate::types::CapacityAdmission { + const DECLARED_ORDER: DeclaredCauseOrder = + DeclaredCauseOrder::declared(&[DeclaredCause::declared( + CauseId::declared( + RefusalFamilyId::declared("root.capacity-admission"), + LocalCauseKey::declared("not-inhabited"), + ), + "NotInhabited", + )]); +} + /// How admitting one refusal family's declaration refuses. /// /// Single cause, because the checks are dependent: there is no order to project diff --git a/src/02_identity/README.md b/src/02_identity/README.md index 5c7b55a..c1a38ed 100644 --- a/src/02_identity/README.md +++ b/src/02_identity/README.md @@ -73,13 +73,32 @@ machinery this home already owns. Rust exports `macro_rules!` at the crate root; that is Rust's macro namespacing rule and not a root admission of a semantic noun, because the stamp declares no type of its own. -The stamped position is private, exactly as a hand-written guard's always was, -and the stamp emits one road in and none out: `positioned` reads a position the -caller already holds under this role, and no accessor hands it back. A role -whose representation could be taken out and re-entered under another role would -be a label rather than a wall, so both directions refuse from outside the -module the stamp expanded in — and the refusal is proven over ONE scope type, -where nothing about the scope is helping. +The stamped position is private, and the stamp emits one road in and none out: +`positioned` reads a position the caller already holds under this role, and no +accessor hands it back. A role whose representation could be taken out and +re-entered under another role would be a label rather than a wall, so both +directions refuse — and the refusal is proven over ONE scope type, where nothing +about the scope is helping. + +**The stamp writes into a module of its own, and that is what makes "none out" a +fact rather than an audit.** Rust's privacy is module-scoped: a `macro_rules!` +expansion lands in the invoking module, so a stamp that wrote the newtype +straight into a home's `types.rs` put the seat within reach of every other type +and implementation in that file. The invocation now names the module — `pub +struct FrameVersion over ReferenceFrameId, seated in mod frame_version;` — and +the stamp emits the guard into it and re-exports the type out. The module's +entire content is the transcriber's output, because nothing hand-written can be +added to a module that exists only inside an expansion. So the complete set of +roads out of a stamped guard is the set the stamp writes, and `rustc` is what +establishes it: from the invoking module `version.0` is `E0616` and +`FrameVersion(position)` is `E0423`. + +The module name is the caller's argument because `macro_rules!` cannot build an +identifier from another identifier on stable and this repository carries no +dependency that can. It is `snake_case` because a module named after its type +trips `non_snake_case`, which the lint wall denies — no attribute suppresses +anything, and two stamps naming one module in one file collide as a duplicate +definition. The machine's production scope guards are stamped. Nine of them were tuple structs whose position field was `pub`, which is both a public constructor and a @@ -91,23 +110,22 @@ derive lines are gone, and the shape they all now have is generated from one place. The hand-written twin survives only on the proof surface, where the law below needs something to compare the stamp against. -**The absence of a road out is derived, because no reversal can state it.** The -two laundering fixtures attempt the roads a caller has today — reading the seat -as `version.0`, re-entering it as `FrameVersion(position)` — and both go on -refusing, byte for byte, after a public `position()` is added to the stamp: the -field is still private and the tuple constructor is still unreachable, so the -recorded diagnostic never moves while the sealed value walks out through a road -with a name. A fixture can only attempt roads somebody thought of, and *no road -out exists* is not a sentence Rust can be asked to refuse. So `cargo xtask -check`'s `stamped-guards-seal-their-position` reads it instead: the population is -every type this stamp is invoked for, derived off the sources, and the seat is -read out of the stamp's own transcriber rather than named in the check — a stamp -reseated over another inner type is judged over the type it actually seals. Both -places a road out can be written are read: the transcriber, where one accessor -would unseal every guard at once, and any implementation beside a guard, which -reaches the private seat because a `macro_rules!` expansion is expanded in the -invoking module. A return, a `Deref` target, and a conversion standing for the -position are all the same road under three spellings. +**The absence of a road out is now the compiler's statement, and no check makes +it.** A repository law used to read every stamped guard and every implementation +beside it, asking whether any of them handed the position back. It failed +repeatedly, and each failure was a Rust shape the reader had not been taught: a +receiver of a different type, a `Box`, a `Vec`, a tuple, an opaque iterator, a +type alias, a nested `Result`, a free function, an implementation for a +reference. The question was *did a person write a leak anywhere in a file +containing dozens of other types*, and it is not answerable without being a +compiler. + +Seating the guard in its own module answers it structurally. The set of roads is +the expansion, and nothing else is inside the wall — so `stamped-guards-seal- +their-position` is deleted rather than repaired, and the claim it used to make +is `E0616`, `E0423` and `E0603` on the two laundering fixtures. That is the +drain running downward: a type that makes the wrong move unrepresentable retires +the law that asserted the move was wrong, and the law goes. ## Delegated by decision diff --git a/src/02_identity/mod.rs b/src/02_identity/mod.rs index fece27c..64ec7ee 100644 --- a/src/02_identity/mod.rs +++ b/src/02_identity/mod.rs @@ -29,19 +29,46 @@ pub use types::{ /// with [`OrderComparison`](types::OrderComparison). Cross-scope order is a cut /// vector, never integers. /// -/// # The representation is not a road -/// -/// The inner [`AuthorityPosition`](types::AuthorityPosition) is a PRIVATE field, -/// exactly as every hand-written guard in the machine already writes it. A -/// stamped guard is a role, and a role's whole content is that this position was -/// positioned under THIS role's authority — so a representation that could be -/// taken out of one role and put into another would make the role a label rather -/// than a wall. Outside the module the stamp expanded in, the tuple form -/// `Role(position)` is not a constructor and `value.0` is not a field: both -/// refuse, and `testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs` -/// is the reversal that proves they do. -/// -/// So the stamp emits ONE road in and NO road out. The road in is +/// # The representation is not a road, and the compiler is what says so +/// +/// The inner [`AuthorityPosition`](types::AuthorityPosition) is a PRIVATE field. +/// A stamped guard is a role, and a role's whole content is that this position +/// was positioned under THIS role's authority — so a representation that could +/// be taken out of one role and put into another would make the role a label +/// rather than a wall. +/// +/// Rust's privacy is MODULE-scoped, so a private field is private to the module +/// the declaration landed in and to that module's descendants. A `macro_rules!` +/// expansion lands in the invoking module, so a stamp that wrote the newtype +/// straight into a home's `types.rs` put the seat within reach of every other +/// type, function and implementation in that file — dozens of them — and the +/// only remaining question was whether a person had written a road out anywhere +/// among them. That question is a whole-file audit, and it was asked twelve +/// times and answered wrong twelve times, in twelve different Rust shapes: a +/// receiver of another type, a wrapper, a collection, a tuple, an opaque +/// iterator, a type alias, a nested `Result`, a free function, a trait +/// implementation for a reference. +/// +/// So the stamp does not write into the invoking module. It writes into a module +/// of its own — `seated in mod ` — and re-exports the type out of it: +/// +/// ```text +/// mod frame_version { // NOTHING is hand-written in here +/// pub struct FrameVersion(AuthorityPosition); +/// impl FrameVersion { … } // exactly the roads this stamp writes +/// } +/// pub use frame_version::FrameVersion; +/// ``` +/// +/// The module's ENTIRE content is this transcriber's output, because a +/// `macro_rules!` expansion is closed: no `#[path]`, no second `mod` block, and +/// no hand-written item can be added to a module that exists only inside an +/// expansion. The complete set of roads out of a stamped guard is therefore +/// exactly the set written below, and it is `rustc` rather than a reader that +/// establishes it: from the invoking module, `version.0` is `E0616` and +/// `FrameVersion(position)` is `E0423`, whatever anybody writes beside them. +/// +/// The set written below is ONE road in and NO road out. The road in is /// `positioned`, which takes a position the caller already holds and says which /// role it is being read under. There is deliberately no accessor: an accessor /// handing back the inner position would re-open the laundering road the private @@ -50,12 +77,34 @@ pub use types::{ /// read the position back out — the one operation a Class-C guard supports is /// the comparison below, and it reads the field from inside. /// +/// # The module name is the caller's, and the caller's alone +/// +/// `macro_rules!` cannot build an identifier out of another identifier on +/// stable, and this repository carries no dependency that can, so the module +/// name arrives as an argument. It is written in `snake_case` because a module +/// named after its type trips `non_snake_case`, which this workspace's lint wall +/// denies — the name says what the module is a home FOR, and the compiler holds +/// the spelling with no attribute suppressing anything. +/// +/// Two stamps naming one module in one file collide as a duplicate definition, +/// so the names cannot silently merge two roles into one seat. +/// +/// # Its stated ceiling +/// +/// The emitted module opens with `use super::*;`, which is how the scope type +/// the caller wrote in ITS module is nameable inside the stamp's. So the scope +/// must be nameable from the invoking module — it always is, since the caller +/// wrote it there — and a scope spelled as a path that needs no import leaves +/// that glob unused, which is a denied warning at the call site rather than +/// anything silent. +/// /// Both operations carry the caller's own `$vis`, so the road in and the /// comparison are reachable exactly as far as the role they serve and never one -/// step further. A guard stamped privately gets private operations, which is -/// what the hand-written twin in `laws.rs` has always written; a guard stamped -/// `pub` exports both. The stamp does not decide a surface the caller did not -/// ask for. +/// step further; the re-export carries it too, and it is the single gate on the +/// type. `$vis` must reach at least the invoking module: a guard stamped with no +/// visibility at all would be sealed inside a module nothing can name, so the +/// proof surface's demonstration guard is stamped `pub(crate)`, which is the +/// reach a bare private guard had before the seat moved. /// /// # Where the stamp lives /// @@ -71,51 +120,63 @@ pub use types::{ /// # pub struct DemoScopeId(u8); /// threadpak::scope_guard_version! { /// /// The demo family's version, positioned by its own authority. -/// pub struct DemoVersion over DemoScopeId; +/// pub struct DemoVersion over DemoScopeId, seated in mod demo_version; /// } +/// # fn main() {} /// ``` +/// +/// The `fn main` is written out because the stamp emits a MODULE, and a module +/// emitted inside the function body rustdoc would otherwise wrap this in has a +/// `super` that is the crate root rather than the caller — so the scope type +/// declared beside the invocation would not be nameable from inside the seat. #[macro_export] macro_rules! scope_guard_version { ( $(#[$note:meta])* - $vis:vis struct $name:ident over $scope:ty; + $vis:vis struct $name:ident over $scope:ty, seated in mod $home:ident; ) => { - $(#[$note])* - #[derive(Debug, Clone, PartialEq, Eq, Hash)] - $vis struct $name($crate::identity::AuthorityPosition<$scope>); + mod $home { + use super::*; - impl $name { - /// The one road in: read one position under this role. - /// - /// The caller supplies a position it already holds; this states - /// which role that position is being read under. There is no road - /// out, and that asymmetry is the point — a representation that - /// could leave this role could be re-entered under another one, and - /// the role would have stopped being a wall. - #[must_use] - $vis fn positioned( - position: $crate::identity::AuthorityPosition<$scope>, - ) -> Self { - Self(position) - } + $(#[$note])* + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + $vis struct $name($crate::identity::AuthorityPosition<$scope>); - /// The one lawful comparison: total within one scope, refused - /// across scopes. Forwards to the Class-C machinery the identity - /// home owns; this stamp adds no comparison of its own. - /// - /// # Errors - /// - /// Returns the `OrderComparison` family body when the two positions - /// do not share one scope. - $vis fn try_cmp_same_scope( - &self, - other: &Self, - ) -> ::core::result::Result< - ::core::cmp::Ordering, - $crate::identity::OrderComparison, - > { - self.0.try_cmp_same_scope(&other.0) + impl $name { + /// The one road in: read one position under this role. + /// + /// The caller supplies a position it already holds; this states + /// which role that position is being read under. There is no + /// road out, and that asymmetry is the point — a representation + /// that could leave this role could be re-entered under another + /// one, and the role would have stopped being a wall. + #[must_use] + $vis fn positioned( + position: $crate::identity::AuthorityPosition<$scope>, + ) -> Self { + Self(position) + } + + /// The one lawful comparison: total within one scope, refused + /// across scopes. Forwards to the Class-C machinery the identity + /// home owns; this stamp adds no comparison of its own. + /// + /// # Errors + /// + /// Returns the `OrderComparison` family body when the two + /// positions do not share one scope. + $vis fn try_cmp_same_scope( + &self, + other: &Self, + ) -> ::core::result::Result< + ::core::cmp::Ordering, + $crate::identity::OrderComparison, + > { + self.0.try_cmp_same_scope(&other.0) + } } } + + $vis use $home::$name; }; } diff --git a/src/04_numeric/types.rs b/src/04_numeric/types.rs index 7ce7793..a8c40a7 100644 --- a/src/04_numeric/types.rs +++ b/src/04_numeric/types.rs @@ -46,7 +46,7 @@ use crate::identity::Commitment; use crate::logic::Truth; -use crate::types::{EvidenceRef, Limit}; +use crate::types::{EvidenceRef, Limit, UnstatedMagnitude}; use crate::value::BoundedText; /// The four constructor axes — the value-shape axes of exact construction. A @@ -122,7 +122,9 @@ impl DecimalScale { /// designations). Magnitude is schema-witnessed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DesignationLimit; -impl Limit for DesignationLimit {} +impl Limit for DesignationLimit { + type Authority = UnstatedMagnitude; +} /// A currency designation as supplied and schema-admitted. Seated here (not at /// the schema home) because the band graph demands it: `Money` at band 04 diff --git a/src/06_authority/types.rs b/src/06_authority/types.rs index f1bbaeb..9194388 100644 --- a/src/06_authority/types.rs +++ b/src/06_authority/types.rs @@ -35,7 +35,9 @@ use crate::identity::{ApplicationScope, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::logic::Decision; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{ + Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, Limit, NonEmptyBounded, UnstatedMagnitude, +}; use crate::value::BoundedText; use core::marker::PhantomData; @@ -81,12 +83,16 @@ pub enum TrustPosture { /// Limit family for threat-profile rows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ThreatProfileLimit; -impl Limit for ThreatProfileLimit {} +impl Limit for ThreatProfileLimit { + type Authority = UnstatedMagnitude; +} /// Limit family for threat-subject designations. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ThreatSubjectLimit; -impl Limit for ThreatSubjectLimit {} +impl Limit for ThreatSubjectLimit { + type Authority = UnstatedMagnitude; +} /// One row of a threat profile: a named subject bound to its posture (AUTHORED /// v1 shape; the profile axes roster is carried in this home's README). @@ -118,7 +124,9 @@ pub trait AuthMethod {} /// Limit family for credential bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CredentialLimit; -impl Limit for CredentialLimit {} +impl Limit for CredentialLimit { + type Authority = UnstatedMagnitude; +} /// An opaque credential supporting one authentication method. Not identity, not /// possession, not a grant. @@ -178,12 +186,16 @@ impl ProofOfPossession { /// Limit family for claim member text and bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ClaimMemberLimit; -impl Limit for ClaimMemberLimit {} +impl Limit for ClaimMemberLimit { + type Authority = UnstatedMagnitude; +} /// Limit family for delegation chains (bounded depth by law). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DelegationLimit; -impl Limit for DelegationLimit {} +impl Limit for DelegationLimit { + type Authority = UnstatedMagnitude; +} /// One link of a delegation chain: names its parent and carries its generation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -281,7 +293,9 @@ impl CapabilityClaim { /// roster's own cardinality — one issue per issue kind, ten at most. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ClaimIssueLimit; -impl Limit for ClaimIssueLimit {} +impl Limit for ClaimIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for ClaimIssueLimit { const MAX: usize = 10; } @@ -433,7 +447,9 @@ pub enum AttenuationAxis { /// Limit family for attenuation axis sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AttenuationLimit; -impl Limit for AttenuationLimit {} +impl Limit for AttenuationLimit { + type Authority = UnstatedMagnitude; +} /// One attenuation operand: which axes it narrows (authored v1 shape; each /// axis's narrowing content rides the normal form). @@ -479,7 +495,9 @@ impl ConstraintSourcePair { /// Limit family for key-scope components. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct KeyScopeLimit; -impl Limit for KeyScopeLimit {} +impl Limit for KeyScopeLimit { + type Authority = UnstatedMagnitude; +} /// One application-declared scope component (tenant, subject, purpose, case, /// record family, application-defined domain). diff --git a/src/08_schema/types.rs b/src/08_schema/types.rs index b0fea55..aefeb63 100644 --- a/src/08_schema/types.rs +++ b/src/08_schema/types.rs @@ -40,7 +40,9 @@ use crate::identity::{ ByteIdentity, Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence, }; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, ReasonId, RefusalFamily}; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{ + Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, Limit, NonEmptyBounded, UnstatedMagnitude, +}; use crate::value::BoundedText; // --------------------------------------------------------------------------- @@ -64,7 +66,7 @@ crate::scope_guard_version! { /// One schema version — Class C, a u64 position scoped to its family with the /// scope binding in the value: the first production instantiation of the /// scope-guarded order shape. No `Ord` exists; comparison is same-scope only. - pub struct SchemaVersion over SchemaFamilyId; + pub struct SchemaVersion over SchemaFamilyId, seated in mod schema_version; } /// The identity role marker for fields. @@ -163,7 +165,9 @@ pub enum ContractAxis { /// Limit family for a contract's declared-axis set. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ContractAxisLimit; -impl Limit for ContractAxisLimit {} +impl Limit for ContractAxisLimit { + type Authority = UnstatedMagnitude; +} /// An authored checked contract declaration (authored v1 core: the declared /// axes; per-axis content rides the declaration surfaces). @@ -392,7 +396,9 @@ impl ValidatedOwned { /// Limit family for field paths. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct FieldPathLimit; -impl Limit for FieldPathLimit {} +impl Limit for FieldPathLimit { + type Authority = UnstatedMagnitude; +} /// One segment of a stable issue path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -417,7 +423,9 @@ pub struct FieldPath { /// Limit family for issue text. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct IssueTextLimit; -impl Limit for IssueTextLimit {} +impl Limit for IssueTextLimit { + type Authority = UnstatedMagnitude; +} /// One structured validation issue — evidence, not prose. Aggregation is /// bounded (hostile input allocates no unbounded error tree; stopping reports @@ -533,7 +541,9 @@ pub struct DynamicValue { /// Limit family for edge text members. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EdgeLimit; -impl Limit for EdgeLimit {} +impl Limit for EdgeLimit { + type Authority = UnstatedMagnitude; +} /// The claim marker for compatibility evidence references. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -710,7 +720,9 @@ pub enum ContractConstructionIssue { /// Compile-time bound for contract issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ContractIssueLimit; -impl Limit for ContractIssueLimit {} +impl Limit for ContractIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for ContractIssueLimit { const MAX: usize = 6; } @@ -776,7 +788,9 @@ pub enum RefinementConstructionIssue { /// Compile-time bound for refinement issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RefinementIssueLimit; -impl Limit for RefinementIssueLimit {} +impl Limit for RefinementIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for RefinementIssueLimit { const MAX: usize = 11; } @@ -862,7 +876,9 @@ pub enum MigrationConstructionIssue { /// Compile-time bound for migration issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MigrationIssueLimit; -impl Limit for MigrationIssueLimit {} +impl Limit for MigrationIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for MigrationIssueLimit { const MAX: usize = 11; } @@ -936,7 +952,9 @@ pub enum CompatibilityEdgeConstructionIssue { /// Compile-time bound for compatibility-edge issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CompatibilityIssueLimit; -impl Limit for CompatibilityIssueLimit {} +impl Limit for CompatibilityIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for CompatibilityIssueLimit { const MAX: usize = 11; } @@ -1018,7 +1036,9 @@ pub enum SchemaConstructionIssue { /// Compile-time bound for schema issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SchemaIssueLimit; -impl Limit for SchemaIssueLimit {} +impl Limit for SchemaIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for SchemaIssueLimit { const MAX: usize = 18; } @@ -1101,7 +1121,9 @@ pub enum LayoutConstructionIssue { /// Compile-time bound for layout issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct LayoutIssueLimit; -impl Limit for LayoutIssueLimit {} +impl Limit for LayoutIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for LayoutIssueLimit { const MAX: usize = 14; } @@ -1179,7 +1201,9 @@ pub enum CodecConstructionIssue { /// Compile-time bound for codec issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CodecIssueLimit; -impl Limit for CodecIssueLimit {} +impl Limit for CodecIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for CodecIssueLimit { const MAX: usize = 17; } diff --git a/src/09_time/types.rs b/src/09_time/types.rs index 028cc7d..c5404ea 100644 --- a/src/09_time/types.rs +++ b/src/09_time/types.rs @@ -47,7 +47,7 @@ use crate::bounds::{Dimension, DimensionId}; use crate::identity::{CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{FamilyShape, RefusalFamily}; -use crate::types::{Bounded, EvidenceRef, Limit}; +use crate::types::{Bounded, EvidenceRef, Limit, UnstatedMagnitude}; use crate::value::BoundedText; use core::marker::PhantomData; @@ -98,7 +98,9 @@ pub struct TimeDelta { /// Limit family for provenance text. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProvenanceLimit; -impl Limit for ProvenanceLimit {} +impl Limit for ProvenanceLimit { + type Authority = UnstatedMagnitude; +} /// Where a reading came from: source, route, admission context. Lost /// provenance defaults to refusal. @@ -123,7 +125,9 @@ pub struct ClockObservation { /// Limit family for clock-source policies. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ClockPolicyLimit; -impl Limit for ClockPolicyLimit {} +impl Limit for ClockPolicyLimit { + type Authority = UnstatedMagnitude; +} /// Admitted clock domains and their admission requirements — policy, never a /// live clock. @@ -394,7 +398,9 @@ pub struct SpendRecord { /// Limit family for spend collections. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SpendLimit; -impl Limit for SpendLimit {} +impl Limit for SpendLimit { + type Authority = UnstatedMagnitude; +} /// The claim marker for the durable coordinate a spend was recorded at. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/10_history/types.rs b/src/10_history/types.rs index 70c7aff..88178d6 100644 --- a/src/10_history/types.rs +++ b/src/10_history/types.rs @@ -29,7 +29,10 @@ use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; use crate::schema::SchemaSemanticCommitment; -use crate::types::{Bounded, Completeness, EvidenceCut, EvidenceRef, Freshness, Limit}; +use crate::types::{ + Bounded, Completeness, DeclaredMagnitude, EvidenceCut, EvidenceRef, Freshness, Limit, + UnstatedMagnitude, +}; use crate::value::BoundedText; // --------------------------------------------------------------------------- @@ -83,14 +86,18 @@ crate::scope_guard_version! { /// contract is fixed per generation: any change that would make old and new /// sequence values incomparable mints a new generation, so the generation a /// value carries names its ordering contract. - pub struct AuthorityGeneration over StoreLineageId; + pub struct AuthorityGeneration over StoreLineageId, seated in mod authority_generation; } impl AuthorityGeneration { /// In-crate mint for laws. Test-gated until admission minting exists. + /// + /// It goes through the stamp's own road in rather than through the tuple + /// form, because the tuple form is not reachable from here: the stamp seats + /// the position inside a module of its own and this file is outside it. #[cfg(test)] pub(crate) fn for_laws(seed: u8) -> Self { - Self(crate::identity::AuthorityPosition::assigned( + Self::positioned(crate::identity::AuthorityPosition::assigned( StoreLineageId::for_laws(Occurrence::for_laws( crate::identity::OccurrenceForm::Fresh([seed; 16]), )), @@ -118,7 +125,7 @@ impl IdentityRole for PartitionId { crate::scope_guard_version! { /// One write-authority epoch — Class C, scoped to its partition. No state /// admits both epochs accepting writes. - pub struct WriteAuthorityEpoch over PartitionId; + pub struct WriteAuthorityEpoch over PartitionId, seated in mod write_authority_epoch; } /// The identity role marker for events. `EventId` deliberately declares NO @@ -398,7 +405,9 @@ pub enum HandoffState { /// Limit family for successor sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SuccessorLimit; -impl Limit for SuccessorLimit {} +impl Limit for SuccessorLimit { + type Authority = UnstatedMagnitude; +} /// The split/merge coverage witness, proving as its own claims: successors /// pairwise disjoint; their union equals the sealed predecessor coverage; each @@ -470,7 +479,9 @@ pub struct CutTranslationWitness { /// Limit family for federation entry sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct FederationLimit; -impl Limit for FederationLimit {} +impl Limit for FederationLimit { + type Authority = UnstatedMagnitude; +} /// Federation composition refusal: checked composition of already-established /// cuts — explicit authority entries, closed source-set membership, @@ -600,7 +611,9 @@ impl CausationEdgeKindId { /// Limit family for causation fan-in (bound value evidence-selected). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct FanInLimit; -impl Limit for FanInLimit {} +impl Limit for FanInLimit { + type Authority = UnstatedMagnitude; +} /// One bounded typed multi-parent causation edge. Correlation is grouping; /// chronology is ordering evidence; store adjacency is integrity structure; @@ -640,7 +653,9 @@ pub struct AcceptedEventRecord { /// Limit family for publication batches. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BatchLimit; -impl Limit for BatchLimit {} +impl Limit for BatchLimit { + type Authority = UnstatedMagnitude; +} /// Stage 3 — the batch crossing: which accepted records crossed the local /// durability boundary — membership and order of the batch, not a cut. A @@ -730,7 +745,9 @@ pub enum DurabilityClaimAxis { /// Limit family for durability profiles. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DurabilityProfileLimit; -impl Limit for DurabilityProfileLimit {} +impl Limit for DurabilityProfileLimit { + type Authority = UnstatedMagnitude; +} /// One durability profile: the claims it makes. An operation requests exactly /// one admitted profile; an adapter proves it or refuses it — no weaker @@ -894,7 +911,9 @@ pub struct ExternallyWitnessedFreshness { /// Limit family for source-region sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RegionLimit; -impl Limit for RegionLimit {} +impl Limit for RegionLimit { + type Authority = UnstatedMagnitude; +} /// The declared source regions a closure claim covers. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -987,12 +1006,16 @@ pub struct HistoryReading { /// Limit family for removal-plan collections. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RemovalPlanLimit; -impl Limit for RemovalPlanLimit {} +impl Limit for RemovalPlanLimit { + type Authority = UnstatedMagnitude; +} /// Limit family for removal text members. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RemovalTextLimit; -impl Limit for RemovalTextLimit {} +impl Limit for RemovalTextLimit { + type Authority = UnstatedMagnitude; +} /// The claim marker for removal evidence references. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -1087,7 +1110,9 @@ pub enum RemovalPlanConstructionIssue { /// Compile-time bound for plan-construction issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RemovalPlanIssueLimit; -impl Limit for RemovalPlanIssueLimit {} +impl Limit for RemovalPlanIssueLimit { + type Authority = DeclaredMagnitude; +} impl crate::types::ConstLimit for RemovalPlanIssueLimit { const MAX: usize = 12; } @@ -1138,7 +1163,9 @@ pub enum RemovalAuthorizationClaimConstructionIssue { /// Compile-time bound for claim-construction issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RemovalClaimIssueLimit; -impl Limit for RemovalClaimIssueLimit {} +impl Limit for RemovalClaimIssueLimit { + type Authority = DeclaredMagnitude; +} impl crate::types::ConstLimit for RemovalClaimIssueLimit { const MAX: usize = 2; } @@ -1193,7 +1220,9 @@ pub enum RemovalRefusalIssue { /// Compile-time bound for removal-refusal issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RemovalRefusalIssueLimit; -impl Limit for RemovalRefusalIssueLimit {} +impl Limit for RemovalRefusalIssueLimit { + type Authority = DeclaredMagnitude; +} impl crate::types::ConstLimit for RemovalRefusalIssueLimit { const MAX: usize = 3; } diff --git a/src/11_navigation/README.md b/src/11_navigation/README.md index 67c24f9..924db62 100644 --- a/src/11_navigation/README.md +++ b/src/11_navigation/README.md @@ -76,9 +76,9 @@ obligations: red: testpak/tests/compile-fail/cross-frame-comparison-on-a-production-guard.rs - id: navigation.a-production-guard-cannot-be-laundered challenge_kind: compile-refusal - green: structural (the position is a private seat of a stamped guard, and the - absence of any road out is derived rather than attempted — see band 02, and - cargo xtask check's stamped-guards-seal-their-position) + green: structural (the position is a private seat of a stamped guard seated + in a module the stamp writes whole, so the complete set of roads out is the + expansion and rustc refuses every other one — see band 02) red: testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs - id: navigation.axis-capabilities-are-declared challenge_kind: compile-refusal diff --git a/src/11_navigation/types.rs b/src/11_navigation/types.rs index 61ccc96..70702d0 100644 --- a/src/11_navigation/types.rs +++ b/src/11_navigation/types.rs @@ -19,7 +19,9 @@ use crate::bounds::SemanticWork; use crate::history::{CommitPoint, FederationCutVector, HistoryCut, SourceClosure, StoreLineageId}; use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{FamilyShape, RefusalFamily}; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Freshness, Limit}; +use crate::types::{ + Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, Freshness, Limit, UnstatedMagnitude, +}; use crate::value::BoundedText; use core::marker::PhantomData; @@ -57,13 +59,15 @@ crate::scope_guard_version! { /// cross-frame answer that road's typed refusal. A version under another /// scope ROLE — a schema's, a profile's — is a different type outright, and /// that is the incomparability the stamp carries in the types. - pub struct FrameVersion over ReferenceFrameId; + pub struct FrameVersion over ReferenceFrameId, seated in mod frame_version; } /// Compile-time bound for an axis's declared capabilities. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AxisCapabilityLimit; -impl Limit for AxisCapabilityLimit {} +impl Limit for AxisCapabilityLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for AxisCapabilityLimit { const MAX: usize = 9; } @@ -197,7 +201,9 @@ pub struct JournalView<'a, Role: AddressRole> { /// Limit family for navigation text members. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NavigationTextLimit; -impl Limit for NavigationTextLimit {} +impl Limit for NavigationTextLimit { + type Authority = UnstatedMagnitude; +} /// Whether a transformation covers its whole source domain. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -403,7 +409,9 @@ pub struct CoordinationProfileDomain; /// Limit family for lawful-alternative sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AlternativeLimit; -impl Limit for AlternativeLimit {} +impl Limit for AlternativeLimit { + type Authority = UnstatedMagnitude; +} /// Claim markers for the fix's evidence members. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/12_port/types.rs b/src/12_port/types.rs index 114744c..c517271 100644 --- a/src/12_port/types.rs +++ b/src/12_port/types.rs @@ -29,7 +29,7 @@ use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{FamilyShape, RefusalFamily}; use crate::schema::SchemaSemanticCommitment; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Limit}; +use crate::types::{Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, Limit}; // --------------------------------------------------------------------------- // Port family identity. @@ -60,7 +60,7 @@ impl PortFamilyId { crate::scope_guard_version! { /// One version of a port family — Class C, scoped to its family: versions of /// different port families are incomparable by type. - pub struct PortFamilyVersion over PortFamilyId; + pub struct PortFamilyVersion over PortFamilyId, seated in mod port_family_version; } // --------------------------------------------------------------------------- @@ -133,7 +133,9 @@ pub enum PortPostcondition { /// Compile-time bound for declared postcondition sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PortPostconditionLimit; -impl Limit for PortPostconditionLimit {} +impl Limit for PortPostconditionLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for PortPostconditionLimit { const MAX: usize = 3; } diff --git a/src/13_declaration/README.md b/src/13_declaration/README.md index ab76cd7..b165069 100644 --- a/src/13_declaration/README.md +++ b/src/13_declaration/README.md @@ -43,17 +43,23 @@ trait, no conversion. ## Bound structure (first production use of the distinction) These collections are bounded by a DECLARED finite issue bound, not roster -cardinality — several issues of one kind are lawful at once — so they ride -plain `Limit` families whose bound values are evidence-selected. The -projection-contract family alone is derivable (5 member kinds + 5 unstatable -claims = 10) and carries the compile-time cap. +cardinality — several issues of one kind are lawful at once — so their +magnitudes are selected by the owner's evidence rather than written in the +source. Those three families say so at the type level by declaring +`EvidenceSelectedLimit`, which is the bound on `PositiveLimitWitness`'s mint: the +sentence that used to sit in a doc comment beside each of them is now a fact a +road can read, and a family that never made the declaration has no road to a +runtime capacity. The projection-contract family alone is derivable (5 member +kinds + 5 unstatable claims = 10) and carries the compile-time cap instead. All four bodies carry the one coupled seat — band 00's `AdmittedPrefix`, holding the issues and the coverage claim the same construction amounts to, read back -through `issues()` and `posture()`. The three whose bound values are still -evidence-selected cannot yet hold that seat's value at all: every -`AdmittedPrefix` mint consumes a compile-time magnitude, so those three become -buildable when their magnitudes are declared, and not before. +through `issues()` and `posture()`. The three on the evidence-selected ladder +cannot yet hold that seat's value at all, and the reason is now narrower than it +was: every `AdmittedPrefix` mint is bounded on `ConstLimit`, so the coupled +package has no runtime-witness road even though the witness it would consume +exists. Those three become buildable when `AdmittedPrefix` gains that road, and +not before; the gap is stated here rather than closed by a road with no caller. ## The closed-roster stamp is the root's; this home consumes it diff --git a/src/13_declaration/types.rs b/src/13_declaration/types.rs index 61eda4a..186e16b 100644 --- a/src/13_declaration/types.rs +++ b/src/13_declaration/types.rs @@ -31,7 +31,10 @@ use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{ + Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, EvidenceSelectedMagnitude, Limit, + NonEmptyBounded, UnstatedMagnitude, +}; use crate::value::BoundedText; // --------------------------------------------------------------------------- @@ -153,7 +156,9 @@ pub struct OriginGraph { /// Limit family for name text. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NameTextLimit; -impl Limit for NameTextLimit {} +impl Limit for NameTextLimit { + type Authority = UnstatedMagnitude; +} /// The authored name: strict versioned Unicode source and display spelling. /// NOT required to be NFC — resolution compares the validated NFC form while @@ -197,7 +202,7 @@ impl ProjectionProfileId { crate::scope_guard_version! { /// One version of a projection profile — Class C, scoped to its profile. - pub struct ProjectionProfileVersion over ProjectionProfileId; + pub struct ProjectionProfileVersion over ProjectionProfileId, seated in mod projection_profile_version; } /// The export alias: the exact target-safe spelling faithfully projecting one @@ -691,12 +696,17 @@ pub enum AuthoredNameConstructionIssue { }, } -/// Limit family for authored-name issues — a DECLARED finite issue bound -/// (several scalars may each violate at once, so the roster's cardinality is -/// not the cap; the bound value is evidence-selected). +/// Limit family for authored-name issues. Several scalars may each violate at +/// once, so the roster's cardinality is not the cap and the magnitude is +/// selected by the owner's evidence rather than declared here — which is what +/// [`crate::types::EvidenceSelectedLimit`] says, and why this family declares no +/// [`crate::types::ConstLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AuthoredNameIssueLimit; -impl Limit for AuthoredNameIssueLimit {} +impl Limit for AuthoredNameIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for AuthoredNameIssueLimit {} /// Authored-name construction: a non-empty bounded canonical issue /// collection, ordered by declared cause order then ascending scalar @@ -814,11 +824,15 @@ pub enum ClosureNamespaceIssue { }, } -/// Limit family for closure-namespace issues — a declared finite bound, -/// evidence-selected. +/// Limit family for closure-namespace issues. Its magnitude is selected by the +/// owner's evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ClosureNamespaceIssueLimit; -impl Limit for ClosureNamespaceIssueLimit {} +impl Limit for ClosureNamespaceIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for ClosureNamespaceIssueLimit {} /// Closure-namespace refusal: the namespace is closed as a whole and checked /// as a whole. Ordering: declared cause order, then the typed source @@ -875,7 +889,9 @@ pub enum ClaimKind { /// Limit family for duplicate-claim site sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DuplicateSiteLimit; -impl Limit for DuplicateSiteLimit {} +impl Limit for DuplicateSiteLimit { + type Authority = UnstatedMagnitude; +} /// The link-resolution issues — five, closed. An export alias derived under /// one projection profile or version and presented against another is a LINK @@ -940,11 +956,15 @@ pub enum LinkResolutionIssue { }, } -/// Limit family for link-resolution issues — a declared finite bound, -/// evidence-selected. +/// Limit family for link-resolution issues. Its magnitude is selected by the +/// owner's evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct LinkResolutionIssueLimit; -impl Limit for LinkResolutionIssueLimit {} +impl Limit for LinkResolutionIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for LinkResolutionIssueLimit {} /// Link-resolution refusal: the linker closes one complete graph in one pass /// and several claims may be defective at once — reporting one is a missing @@ -1011,7 +1031,9 @@ pub enum ProjectionClaim { /// Compile-time bound for a contract's stated claims. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProjectionClaimLimit; -impl Limit for ProjectionClaimLimit {} +impl Limit for ProjectionClaimLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for ProjectionClaimLimit { const MAX: usize = 5; } @@ -1065,7 +1087,9 @@ pub enum ProjectionContractConstructionIssue { /// at most five unstated claims. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProjectionIssueLimit; -impl Limit for ProjectionIssueLimit {} +impl Limit for ProjectionIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for ProjectionIssueLimit { const MAX: usize = 10; } diff --git a/src/14_semantic/types.rs b/src/14_semantic/types.rs index 2aee0ca..6d532bf 100644 --- a/src/14_semantic/types.rs +++ b/src/14_semantic/types.rs @@ -40,7 +40,7 @@ use crate::bounds::{BoundClass, DimensionId}; use crate::declaration::Stage; use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole}; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; -use crate::types::{Bounded, Limit, NonEmptyBounded}; +use crate::types::{Bounded, EvidenceSelectedMagnitude, Limit, NonEmptyBounded, UnstatedMagnitude}; // --------------------------------------------------------------------------- // The phase root and its identity. @@ -198,11 +198,15 @@ pub enum SemanticFormConstructionIssue { }, } -/// Limit family for Semantic Form issues — a declared finite bound, -/// evidence-selected (several issues of one kind are lawful at once). +/// Limit family for Semantic Form issues. Several issues of one kind are lawful +/// at once, so the magnitude is selected by the owner's evidence rather than +/// declared here — see [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SemanticFormIssueLimit; -impl Limit for SemanticFormIssueLimit {} +impl Limit for SemanticFormIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for SemanticFormIssueLimit {} /// Semantic Form construction. Completion posture rule: complete diagnosis /// is PERMITTED, NEVER REQUIRED, AND NEVER ASSUMED — a first-party producer @@ -261,7 +265,9 @@ pub struct RefusalFamilyRefDomain; /// Limit family for a judgment's refusal set. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RefusalSetLimit; -impl Limit for RefusalSetLimit {} +impl Limit for RefusalSetLimit { + type Authority = UnstatedMagnitude; +} /// The typed refusal families a judgment declares — AUTHORED thin carrier. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -277,7 +283,9 @@ pub struct EffectRegionDomain; /// Limit family for a judgment's effect regions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EffectRegionLimit; -impl Limit for EffectRegionLimit {} +impl Limit for EffectRegionLimit { + type Authority = UnstatedMagnitude; +} /// The declared effects PLUS their first-observable ordering — the order is /// the collection's own order, carried by construction. AUTHORED thin @@ -295,7 +303,9 @@ pub struct CapabilityRequirementDomain; /// Limit family for a judgment's capability requirements. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CapabilityRequirementLimit; -impl Limit for CapabilityRequirementLimit {} +impl Limit for CapabilityRequirementLimit { + type Authority = UnstatedMagnitude; +} /// Capability REQUIREMENTS — never grants; composition unions requirements /// and can never union grants. AUTHORED thin carrier. @@ -329,7 +339,9 @@ pub struct BoundDimensionRow { /// Limit family for a judgment's symbolic bounds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SymbolicBoundLimit; -impl Limit for SymbolicBoundLimit {} +impl Limit for SymbolicBoundLimit { + type Authority = UnstatedMagnitude; +} /// The bounded canonical collection of the dimensions that ACTUALLY APPLY to /// one judgment — never a padded universal struct. diff --git a/src/15_execution/types.rs b/src/15_execution/types.rs index 2ec1a9d..c188f14 100644 --- a/src/15_execution/types.rs +++ b/src/15_execution/types.rs @@ -32,7 +32,10 @@ use crate::bounds::DimensionId; use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; use crate::semantic::BoundDimensionRow; -use crate::types::{Bounded, ConstLimit, EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{ + Bounded, ConstLimit, DeclaredMagnitude, EvidenceRef, EvidenceSelectedMagnitude, Limit, + NonEmptyBounded, UnstatedMagnitude, +}; // --------------------------------------------------------------------------- // The authored operator register (v1) and Execution-Form identity. @@ -116,7 +119,7 @@ crate::scope_guard_version! { /// One Execution-Form version — Class C, scoped to its family. Adding, /// removing, or changing an operator advances this version; no version is /// bare, and numeric comparison across families is undefined. - pub struct ExecutionFormVersion over ExecutionFormFamilyId; + pub struct ExecutionFormVersion over ExecutionFormFamilyId, seated in mod execution_form_version; } // --------------------------------------------------------------------------- @@ -144,7 +147,9 @@ pub enum AlgebraicLaw { /// Compile-time bound for declared algebraic laws. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AlgebraicLawLimit; -impl Limit for AlgebraicLawLimit {} +impl Limit for AlgebraicLawLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for AlgebraicLawLimit { const MAX: usize = 5; } @@ -171,7 +176,9 @@ pub struct OriginEdgeDomain; /// Limit family for an operator's work-charge rows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WorkChargeLimit; -impl Limit for WorkChargeLimit {} +impl Limit for WorkChargeLimit { + type Authority = UnstatedMagnitude; +} /// One operator's declaration — the seven facts every operator states. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -322,11 +329,15 @@ pub enum ExecutionFormConstructionIssue { }, } -/// Limit family for Execution Form issues — a declared finite bound, -/// evidence-selected. +/// Limit family for Execution Form issues. Its magnitude is selected by the +/// owner's evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ExecutionFormIssueLimit; -impl Limit for ExecutionFormIssueLimit {} +impl Limit for ExecutionFormIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for ExecutionFormIssueLimit {} /// Execution Form construction. Posture addition over the Semantic Form /// family: the independent reference lowerer posts `EarlyStopped` at its @@ -396,7 +407,9 @@ pub const INDEPENDENCE_MAY_NOT_SHARE: [&str; 11] = [ /// Limit family for lexicographic measure tuples. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct LexicographicLimit; -impl Limit for LexicographicLimit {} +impl Limit for LexicographicLimit { + type Authority = UnstatedMagnitude; +} /// The closed, independently executable measure algebra: bounded naturals and /// lexicographic tuples of them, under an admitted well-founded order — never @@ -558,12 +571,16 @@ pub struct EffectCommand { /// Limit family for a batch's commands. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BatchCommandLimit; -impl Limit for BatchCommandLimit {} +impl Limit for BatchCommandLimit { + type Authority = UnstatedMagnitude; +} /// Limit family for a batch's declared bounds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BatchBoundLimit; -impl Limit for BatchBoundLimit {} +impl Limit for BatchBoundLimit { + type Authority = UnstatedMagnitude; +} /// Boundary-requirement domain marker. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -688,10 +705,15 @@ pub enum EffectBatchCompositionIssue { }, } -/// Limit family for composition issues — a declared finite bound. +/// Limit family for composition issues. Its magnitude is selected by the +/// owner's evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EffectBatchIssueLimit; -impl Limit for EffectBatchIssueLimit {} +impl Limit for EffectBatchIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for EffectBatchIssueLimit {} /// Effect-batch composition. Posture: `Complete` when every applicable check /// ran; `EarlyStopped` only when (a) an established issue makes remaining @@ -757,7 +779,7 @@ impl SemanticKernelFamilyId { crate::scope_guard_version! { /// One semantic-kernel version — Class C, ordered ONLY within its family; no /// version is bare. - pub struct SemanticKernelVersion over SemanticKernelFamilyId; + pub struct SemanticKernelVersion over SemanticKernelFamilyId, seated in mod semantic_kernel_version; } /// Kernel semantic-contract domain marker (the MEANING half). @@ -920,7 +942,9 @@ pub struct KernelRequirement { /// Limit family for kernel requirement sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct KernelSetLimit; -impl Limit for KernelSetLimit {} +impl Limit for KernelSetLimit { + type Authority = UnstatedMagnitude; +} /// The bounded canonical kernel-requirement set — duplicates and /// contradictions refuse. @@ -976,7 +1000,9 @@ crate::closed_register! { /// Limit family for semantic-contract issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct KernelSemanticIssueLimit; -impl Limit for KernelSemanticIssueLimit {} +impl Limit for KernelSemanticIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for KernelSemanticIssueLimit { /// The register's own cardinality, read off the register. /// @@ -1079,7 +1105,9 @@ crate::closed_register! { /// Limit family for interface-contract issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct KernelInterfaceIssueLimit; -impl Limit for KernelInterfaceIssueLimit {} +impl Limit for KernelInterfaceIssueLimit { + type Authority = DeclaredMagnitude; +} impl ConstLimit for KernelInterfaceIssueLimit { /// The register's own cardinality, read off the register — see /// [`KernelSemanticIssueLimit::MAX`] for why a bound that is the roster's diff --git a/src/16_image/types.rs b/src/16_image/types.rs index 2952b23..2b2d5c7 100644 --- a/src/16_image/types.rs +++ b/src/16_image/types.rs @@ -33,7 +33,7 @@ use crate::bytes::ContentRegionId; use crate::execution::KernelRequirementSet; use crate::identity::{ByteIdentity, CreationLaw, IdentityClass, IdentityRole, Occurrence}; -use crate::types::{Bounded, EvidenceRef, Limit}; +use crate::types::{Bounded, EvidenceRef, Limit, UnstatedMagnitude}; // --------------------------------------------------------------------------- // The image identities. @@ -96,7 +96,7 @@ impl ImageFamilyId { crate::scope_guard_version! { /// One image-family format version — Class C, ordered ONLY within its family. - pub struct ImageFamilyFormatVersion over ImageFamilyId; + pub struct ImageFamilyFormatVersion over ImageFamilyId, seated in mod image_family_format_version; } /// The identity role marker for image profiles. @@ -126,7 +126,7 @@ crate::scope_guard_version! { /// image-bytes, runtime, and release support do not move together; an unknown /// operation, version, profile, import, or kernel is refused, never silently /// ignored. - pub struct ImageProfileVersion over ImageProfileId; + pub struct ImageProfileVersion over ImageProfileId, seated in mod image_profile_version; } /// The identity role marker for admitted programs. @@ -242,7 +242,9 @@ pub enum PackagingProfile { /// Limit family for an image's components. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ComponentLimit; -impl Limit for ComponentLimit {} +impl Limit for ComponentLimit { + type Authority = UnstatedMagnitude; +} /// One directly executable program's package: a root binding a set of typed /// components plus its import/immutable-resource/required-kernel closure. diff --git a/src/17_pakvm/types.rs b/src/17_pakvm/types.rs index e1fffc9..9287cbf 100644 --- a/src/17_pakvm/types.rs +++ b/src/17_pakvm/types.rs @@ -34,7 +34,7 @@ use crate::identity::Commitment; use crate::semantic::BoundDimensionRow; use crate::time::ConsumedBudgetEvidence; -use crate::types::{Bounded, EvidenceRef, Limit}; +use crate::types::{Bounded, EvidenceRef, Limit, UnstatedMagnitude}; use core::marker::PhantomData; // --------------------------------------------------------------------------- @@ -182,7 +182,9 @@ pub struct CaptureOriginClaim; /// Limit family for capture environments. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CaptureLimit; -impl Limit for CaptureLimit {} +impl Limit for CaptureLimit { + type Authority = UnstatedMagnitude; +} /// Every portable function or lambda lowers into a closed semantic /// definition plus this bounded typed capture record — the minimal semantic @@ -297,7 +299,9 @@ pub struct ContinuationPostureDomain; /// Limit family for a continuation's remaining bounds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ContinuationBoundLimit; -impl Limit for ContinuationBoundLimit {} +impl Limit for ContinuationBoundLimit { + type Authority = UnstatedMagnitude; +} /// The explicit typed one-shot continuation record — suspension lowers into /// THIS, never a retained Rust closure, native stack, host callback, task, diff --git a/src/18_bvisor/types.rs b/src/18_bvisor/types.rs index 6bf7fb7..9083af6 100644 --- a/src/18_bvisor/types.rs +++ b/src/18_bvisor/types.rs @@ -37,7 +37,9 @@ use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occu use crate::port::{PortFamilyVersion, PortPostcondition}; use crate::refusal::{AdmittedPrefix, CompletionPosture, FamilyShape, RefusalFamily}; use crate::semantic::BoundDimensionRow; -use crate::types::{Bounded, EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{ + Bounded, EvidenceRef, EvidenceSelectedMagnitude, Limit, NonEmptyBounded, UnstatedMagnitude, +}; use core::marker::PhantomData; // --------------------------------------------------------------------------- @@ -363,10 +365,15 @@ pub enum AttemptAdmissionIssue { }, } -/// Limit family for admission issues — a declared finite bound. +/// Limit family for admission issues. Its magnitude is selected by the owner's +/// evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AdmissionIssueLimit; -impl Limit for AdmissionIssueLimit {} +impl Limit for AdmissionIssueLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for AdmissionIssueLimit {} /// The admission refusal family. The INVERSION RULE fixes its shape: a /// canonical body that discards a second established violation reports less @@ -794,7 +801,9 @@ pub struct PortRequestPayloadDomain; /// Limit family for a request's bound rows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PortRequestBoundLimit; -impl Limit for PortRequestBoundLimit {} +impl Limit for PortRequestBoundLimit { + type Authority = UnstatedMagnitude; +} /// A typed claim made by ONE live Attempt. The port receives least authority /// and only the data that request needs — no ambient access to the runtime, diff --git a/src/19_runtime/types.rs b/src/19_runtime/types.rs index 2db7ed9..947543e 100644 --- a/src/19_runtime/types.rs +++ b/src/19_runtime/types.rs @@ -39,7 +39,7 @@ use crate::bvisor::{AttemptId, ReservationObservation}; use crate::history::CommitKnowledge; use crate::identity::{Commitment, CreationLaw, IdentityClass, IdentityRole, Occurrence}; -use crate::types::{Bounded, EvidenceRef, Limit}; +use crate::types::{Bounded, EvidenceRef, Limit, UnstatedMagnitude}; // --------------------------------------------------------------------------- // The Stitch contract and driver invariance. @@ -247,7 +247,9 @@ pub enum AttemptCause { /// Limit family for cause sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CauseSetLimit; -impl Limit for CauseSetLimit {} +impl Limit for CauseSetLimit { + type Authority = UnstatedMagnitude; +} /// A SET under a declared bound: membership is what it states — the same /// causes in a different order are the same value. Storage order may be made @@ -367,7 +369,9 @@ pub struct IdempotencyScopeDomain; /// Limit family for supported-key sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct KeySupportLimit; -impl Limit for KeySupportLimit {} +impl Limit for KeySupportLimit { + type Authority = UnstatedMagnitude; +} /// What the effect contract supports — `None` is the explicit weaker posture /// recorded UP FRONT, never missing data (how a stable key is established is diff --git a/src/20_derived/types.rs b/src/20_derived/types.rs index 6953c10..58fbed0 100644 --- a/src/20_derived/types.rs +++ b/src/20_derived/types.rs @@ -77,7 +77,7 @@ impl MaterializationId { crate::scope_guard_version! { /// One materialization generation — Class C, scoped to its materialization; /// CHANGES on rematerialization. - pub struct MaterializationGeneration over MaterializationId; + pub struct MaterializationGeneration over MaterializationId, seated in mod materialization_generation; } /// Row-domain preimage domain marker. diff --git a/src/21_application/types.rs b/src/21_application/types.rs index 47bef68..f4d5687 100644 --- a/src/21_application/types.rs +++ b/src/21_application/types.rs @@ -105,7 +105,7 @@ crate::scope_guard_version! { /// scope and order ONLY (no image identity in the scope bytes): an image /// upgrade is one reason the generation order advances, and generations stay /// ordered across it. - pub struct ActivationGeneration over InstanceId; + pub struct ActivationGeneration over InstanceId, seated in mod activation_generation; } /// Which image a generation activated — an auxiliary fact riding a typed diff --git a/src/22_security/types.rs b/src/22_security/types.rs index b3b528a..3c6097e 100644 --- a/src/22_security/types.rs +++ b/src/22_security/types.rs @@ -34,7 +34,7 @@ use crate::authority::CapabilityGrantId; use crate::identity::Commitment; -use crate::types::{EvidenceRef, Limit, NonEmptyBounded}; +use crate::types::{EvidenceRef, EvidenceSelectedMagnitude, Limit, NonEmptyBounded}; // --------------------------------------------------------------------------- // The lease — the band-forced seat from the authority home, collected. @@ -271,10 +271,17 @@ pub struct IndexInvalidationClaim; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ResultingResolutionDomain; -/// Limit family for shred participants. +/// Limit family for shred participants. A denominator's participant set is as +/// wide as the generation it is about, so the magnitude is selected by the +/// owner's evidence rather than declared here — see +/// [`crate::types::EvidenceSelectedLimit`]. The only family in this crate on +/// that ladder whose seat is not a refusal body. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ShredParticipantLimit; -impl Limit for ShredParticipantLimit {} +impl Limit for ShredParticipantLimit { + type Authority = EvidenceSelectedMagnitude; +} +impl crate::types::EvidenceSelectedLimit for ShredParticipantLimit {} /// Shred is acknowledged only after every required backend has durably /// destroyed the relevant key authority and produced THIS evidence. Shred diff --git a/src/23_evidence/types.rs b/src/23_evidence/types.rs index d4bb0a5..5a976fd 100644 --- a/src/23_evidence/types.rs +++ b/src/23_evidence/types.rs @@ -36,7 +36,7 @@ //! imply successful verification. use crate::identity::Commitment; -use crate::types::{Bounded, Completeness, EvidenceRef, Limit}; +use crate::types::{Bounded, Completeness, EvidenceRef, Limit, UnstatedMagnitude}; // --------------------------------------------------------------------------- // The non-collapse law and the receipt-family matrix. @@ -118,7 +118,9 @@ pub struct CommitmentLayerDomain; /// Limit family for commitment layers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CommitmentLayerLimit; -impl Limit for CommitmentLayerLimit {} +impl Limit for CommitmentLayerLimit { + type Authority = UnstatedMagnitude; +} /// Commitment layers COEXIST — a digest AND a signature AND a freshness /// witness prove different claims, so a pick-one enum is the refusal. Each @@ -388,7 +390,9 @@ pub struct DiagnosticCause(pub Commitment); /// Limit family for narrowed suspect sets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CauseSuspectLimit; -impl Limit for CauseSuspectLimit {} +impl Limit for CauseSuspectLimit { + type Authority = UnstatedMagnitude; +} /// The bounded narrowed-suspect set — its public meaning is "narrowed /// suspects", never a raw bounded vector and never a bare universal cause. diff --git a/src/laws.rs b/src/laws.rs index b8b9be6..c076b84 100644 --- a/src/laws.rs +++ b/src/laws.rs @@ -22,8 +22,9 @@ fn pairwise_distinct(items: &[T]) -> bool { mod root { use crate::types::{ - Bounded, Completeness, ConstLimit, Dispatch, EvidenceCut, Freshness, Limit, LimitWitness, - Never, TransitionSystem, + Bounded, Completeness, ConstLimit, DeclaredMagnitude, Dispatch, EvidenceCut, + EvidenceSelectedMagnitude, Freshness, Limit, LimitWitness, Never, TransitionSystem, + UnstatedMagnitude, }; /// law: root.cut-families-are-caller-supplied — any owner can bind `Freshness` @@ -70,12 +71,16 @@ mod root { #[test] fn limit_families_do_not_unify() { struct DecodeDemo; - impl Limit for DecodeDemo {} + impl Limit for DecodeDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for DecodeDemo { const MAX: usize = 8; } struct ArenaDemo; - impl Limit for ArenaDemo {} + impl Limit for ArenaDemo { + type Authority = UnstatedMagnitude; + } let decode_bounded: Option)> = Some(drop); let arena_bounded: Option)> = Some(drop); @@ -153,7 +158,9 @@ mod root { NonEmptyBoundedConstruction, PositiveLimit, RootLawsProfile, }; struct SmallDemo; - impl Limit for SmallDemo {} + impl Limit for SmallDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for SmallDemo { const MAX: usize = 2; } @@ -215,12 +222,16 @@ mod root { fn admission_precedes_a_trusted_magnitude() { use crate::types::{AdmittedLimit, Bounded, LimitAdmissionProfile, RootLawsProfile}; struct AdmissibleDemo; - impl Limit for AdmissibleDemo {} + impl Limit for AdmissibleDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for AdmissibleDemo { const MAX: usize = 4; } struct OtherDemo; - impl Limit for OtherDemo {} + impl Limit for OtherDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for OtherDemo { const MAX: usize = 4; } @@ -270,12 +281,16 @@ mod root { AdmittedLimit, Bounded, NonEmptyBounded, PositiveLimit, RootLawsProfile, }; struct EmptyOnlyDemo; - impl Limit for EmptyOnlyDemo {} + impl Limit for EmptyOnlyDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for EmptyOnlyDemo { const MAX: usize = 0; } struct InhabitedDemo; - impl Limit for InhabitedDemo {} + impl Limit for InhabitedDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for InhabitedDemo { const MAX: usize = 3; } @@ -326,7 +341,9 @@ mod root { fn the_positive_witness_carries_the_admitted_one() { use crate::types::{AdmittedLimit, PositiveLimit, RootLawsProfile}; struct ContainedDemo; - impl Limit for ContainedDemo {} + impl Limit for ContainedDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for ContainedDemo { const MAX: usize = 5; } @@ -337,6 +354,262 @@ mod root { assert_eq!(positive.max(), ContainedDemo::MAX); } + /// law: root.a-runtime-capacity-is-witnessed-positive — an evidence-selected + /// magnitude becomes a capacity a road promising an inhabitant may act on + /// only through the stronger runtime witness, and the mint refuses a + /// selection admitting no item. + /// + /// Both halves stand here, because the split is only honest if both do. A + /// witnessed magnitude of zero is a lawful selection for a seat that holds + /// nothing — `Bounded::admitted` under it yields a real empty collection — + /// and the base witness admits it on purpose. The same selection cannot + /// mint `PositiveLimitWitness`, which is exactly the evidence + /// `NonEmptyBounded::admitted` demands, because that road promises a first + /// item no zero capacity can supply. + /// + /// This is the rung that was missing. `admitted_const` took the strong + /// witness and `admitted` took the weak one, so the same promise stood on + /// different evidence depending only on which road the magnitude arrived + /// by. With this road closed, EVERY constructor of the + /// inhabitant-promising shape consumes evidence that the family admits an + /// item — the two `const` roads prove it off the declaration, + /// `admitted_const` and `admitted_prefix` take `PositiveLimit`, and this one + /// takes `PositiveLimitWitness`. That claim is total rather than sampled + /// because the shape's seats are private: no road into it can exist outside + /// this file and its guarded child. + /// + /// Non-vacuity is executed rather than asserted: the refusing call and the + /// admitting call differ in the magnitude alone — same family, same road, + /// same item count — so the refusal cannot be coming from anything else. + /// + /// The claim ceiling: this says nothing about whether the selected + /// magnitude is the RIGHT one for the family's domain. The owner profile + /// and the evidence select that, and no road here can check it. + /// + /// Red twin: a zero capacity REFUSES rather than failing to compile, and + /// the reason is structural — a magnitude that does not exist until runtime + /// has no compile-time value for a `const` block to read. So the reversal is + /// a behavioral hostile rather than a fixture, and it is executed below, on + /// the refusing arm. Driving that same refusal from OUTSIDE the crate stays + /// OWED and is gated rather than merely unwritten: `LimitWitness` has only + /// its `cfg(test)` mint, so no outside consumer can build the zero selection + /// this law refuses. The gate comes off with the schema home's lawful + /// minter. Where the same relation IS visible in the source it takes the + /// stronger seat instead: that is `root.positivity-is-the-stronger-witness`, + /// whose `const` gate stops a zero-maximum family at compile time and whose + /// fixture is testpak's. + #[test] + fn a_runtime_capacity_is_witnessed_positive() { + use crate::types::{ + Bounded, CapacityAdmission, EvidenceSelectedLimit, NonEmptyBounded, + NonEmptyBoundedConstruction, PositiveLimitWitness, + }; + struct SelectedDemo; + impl Limit for SelectedDemo { + type Authority = EvidenceSelectedMagnitude; + } + impl EvidenceSelectedLimit for SelectedDemo {} + + // The weak witness admits a zero selection, and the seat under it is a + // real empty collection rather than a mistake. + let nothing: LimitWitness = LimitWitness::declared(0); + assert_eq!(nothing.max(), 0); + let empty: Result, _> = Bounded::admitted(vec![], ¬hing); + assert!(empty.is_ok_and(|bounded| bounded.is_empty())); + + // The strong witness refuses exactly that selection. + assert_eq!( + PositiveLimitWitness::inhabited(LimitWitness::::declared(0)).err(), + Some(CapacityAdmission::NotInhabited) + ); + + // And admits the next magnitude up. One number moved; nothing else did. + let capacity = PositiveLimitWitness::inhabited(LimitWitness::::declared(1)) + .unwrap_or_else(|_| unreachable!("one admits an item")); + assert_eq!(capacity.max(), 1); + + // The road that promises an inhabitant takes the strong witness, and + // reports both what the capacity holds and what it does not. + let held: Result, _> = + NonEmptyBounded::admitted(7, vec![], &capacity); + assert!(held.is_ok_and(|value| value.len() == 1 && *value.first() == 7)); + let over: Result, _> = + NonEmptyBounded::admitted(7, vec![8], &capacity); + assert!(matches!(over, Err(NonEmptyBoundedConstruction::OverLimit))); + } + + /// law: root.a-capacity-witness-does-not-cross-families — a runtime capacity + /// names WHICH family's magnitude it admitted, so one family's capacity is + /// never another's whatever the two numbers are. + /// + /// The family rides on the CONTAINED witness's own type parameter rather + /// than on a tag this type keeps beside it, so there is no second statement + /// of which family was admitted and nothing here to drift from the + /// selection it came from. + /// + /// The claim ceiling: this says nothing about which family is right for a + /// seat. It says a road that requires one family's capacity cannot be fed + /// another's. + /// + /// Red twin: substituting one family's capacity where another's is required + /// must not compile — + /// testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs. + #[test] + fn a_capacity_witness_does_not_cross_families() { + use crate::types::{EvidenceSelectedLimit, PositiveLimitWitness}; + struct FirstDemo; + impl Limit for FirstDemo { + type Authority = EvidenceSelectedMagnitude; + } + impl EvidenceSelectedLimit for FirstDemo {} + struct SecondDemo; + impl Limit for SecondDemo { + type Authority = EvidenceSelectedMagnitude; + } + impl EvidenceSelectedLimit for SecondDemo {} + + let first = PositiveLimitWitness::inhabited(LimitWitness::::declared(4)) + .unwrap_or_else(|_| unreachable!("four admits an item")); + let second = PositiveLimitWitness::inhabited(LimitWitness::::declared(4)) + .unwrap_or_else(|_| unreachable!("four admits an item")); + assert_eq!(first.max(), second.max()); + + // Two capacities of one magnitude, and the two values do not unify: + // each consumer names the family it will take, and only that one fits. + let takes_first: fn(PositiveLimitWitness) = drop; + let takes_second: fn(PositiveLimitWitness) = drop; + takes_first(first); + takes_second(second); + } + + /// law: root.the-runtime-ladder-is-declared-by-its-family — a family reaches + /// a runtime capacity only where its owner declared the magnitude + /// evidence-selected. The declaration is the MINT'S BOUND rather than a + /// sentence beside the family, so a family that never made it has no road to + /// a capacity at all. + /// + /// The green half is that the bound is real and satisfiable: a family + /// declaring it reaches the mint, settled by the compiler over a function + /// pointer with nothing executed. The half that matters is the red one, + /// because a bound nothing fails is a bound nobody needed. + /// + /// The claim ceiling. This declaration says the magnitude arrives at + /// runtime, and it says nothing about the population: that every family in + /// this crate whose seat promises an inhabitant has made the declaration is + /// a POPULATION question, it is answered by deriving the population from the + /// sources rather than from a list anybody maintains, and no list of + /// families is written here, because such a list would be exactly the + /// hand-maintained inventory this repository bans. + /// + /// It used to carry a second ceiling — that a family stating BOTH ladders + /// would be stating two authorities for one capacity, and that no bound here + /// could see it. That ceiling is gone rather than moved: `Limit::Authority` + /// resolves to one type, the two ladders name theirs exactly, and the second + /// declaration is a type mismatch. See + /// `root::a_family_declares_one_capacity_authority` for the positive control + /// and its reversal. + /// + /// What answers half of that question today is the red twin's own recorded + /// diagnostic. `rustc` reports an unsatisfied bound by listing the types + /// that satisfy it, so the committed `.stderr` carries the roster of every + /// family on this ladder, derived from the impls rather than authored — and + /// a family joining or leaving the ladder moves that file and fails the + /// fixture. It is a DRIFT DETECTOR over one side of the join, not a count: + /// it sees families that are on the ladder and cannot see a seat that + /// promises an inhabitant while its family stays off it. That second side + /// is a repository join over the sources, and + /// `cargo xtask check`'s `inhabitant-promising-limits-are-witnessed` is + /// where it is derived — the run prints the numerator over the denominator, + /// and the same reading refuses a terminal name two homes declare, because a + /// population that cannot say which family a seat named has no denominator + /// to count. + /// + /// Red twin: minting a capacity for a family that never declared its + /// magnitude evidence-selected must not compile — + /// testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs. + #[test] + fn the_runtime_ladder_is_declared_by_its_family() { + use crate::types::{CapacityAdmission, EvidenceSelectedLimit, PositiveLimitWitness}; + struct DeclaredDemo; + impl Limit for DeclaredDemo { + type Authority = EvidenceSelectedMagnitude; + } + impl EvidenceSelectedLimit for DeclaredDemo {} + + let mint: fn( + LimitWitness, + ) -> Result, CapacityAdmission> = + PositiveLimitWitness::inhabited; + assert!(mint(LimitWitness::declared(2)).is_ok_and(|held| held.max() == 2)); + } + + /// law: root.a-family-declares-one-capacity-authority — the two ladders are + /// reachable, one family at a time, and the authority a family declares is + /// what decides which one it reaches. + /// + /// # This law is the POSITIVE CONTROL and nothing else + /// + /// The exclusion itself is not asserted here and could not be: a family + /// declaring both authorities does not compile, so there is no expression in + /// this file that could hold it. `rustc` owns that half, the reversal is + /// named below, and restating the exclusion at this weaker seat would leave + /// a sentence that keeps passing after the supertrait bounds are gone. + /// + /// What remains here is the half `rustc` cannot volunteer: that the two + /// bounds are SATISFIABLE. A pair of ladders nothing could implement would + /// refuse every reversal ever written against it and guard nothing, so the + /// two families below are the evidence that the refusal is a refusal of one + /// case rather than of all of them. Each declares one authority, and each + /// reaches exactly the mint that authority admits. + /// + /// Red twin: + /// `testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs` + /// — the same declaration plus the other ladder, refused at the declaration + /// with a type mismatch on `::Authority`. + #[test] + fn a_family_declares_one_capacity_authority() { + use crate::types::{ + AdmittedLimit, CapacityAdmission, EvidenceSelectedLimit, PositiveLimitWitness, + RootLawsProfile, + }; + struct DeclaredAuthorityDemo; + impl Limit for DeclaredAuthorityDemo { + type Authority = DeclaredMagnitude; + } + impl ConstLimit for DeclaredAuthorityDemo { + const MAX: usize = 6; + } + + struct SelectedAuthorityDemo; + impl Limit for SelectedAuthorityDemo { + type Authority = EvidenceSelectedMagnitude; + } + impl EvidenceSelectedLimit for SelectedAuthorityDemo {} + + // The third state is a real one, and the largest population in the + // crate: a family that declared neither authority. A law that could not + // name it would be describing a two-state world the tree does not have. + struct UnstatedAuthorityDemo; + impl Limit for UnstatedAuthorityDemo { + type Authority = UnstatedMagnitude; + } + + // The declared authority reaches the compile-time road, and only it. + let admitted: AdmittedLimit = + AdmittedLimit::under_profile(); + assert_eq!(admitted.max(), DeclaredAuthorityDemo::MAX); + + // The evidence-selected authority reaches the runtime road, and only it. + let capacity: Result, CapacityAdmission> = + PositiveLimitWitness::inhabited(LimitWitness::declared(6)); + assert!(capacity.is_ok_and(|held| held.max() == 6)); + + // The unstated authority reaches neither mint, and its bounded seat + // still exists: a family needs no magnitude to hold an empty collection. + let seat: Bounded = Bounded::empty(); + assert!(seat.is_empty()); + } + /// law: root.a-prefix-road-reports-what-it-did-not-carry — the one /// construction road that truncates reports the truncation it performed, /// both directions: material that fits is carried whole and reports nothing @@ -364,7 +637,9 @@ mod root { fn a_prefix_road_reports_what_it_did_not_carry() { use crate::types::{NonEmptyBounded, PositiveLimit, RootLawsProfile}; struct PrefixDemo; - impl Limit for PrefixDemo {} + impl Limit for PrefixDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for PrefixDemo { const MAX: usize = 3; } @@ -414,7 +689,9 @@ mod root { AdmittedLimit, LimitAdmissionProfile, NarrowLawsProfile, RootLawsProfile, }; struct TinyDemo; - impl Limit for TinyDemo {} + impl Limit for TinyDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for TinyDemo { const MAX: usize = 4; } @@ -457,7 +734,9 @@ mod root { fn reading_is_not_gaining() { use crate::types::{AdmittedLimit, Bounded, NonEmptyBounded, RootLawsProfile}; struct ReadDemo; - impl Limit for ReadDemo {} + impl Limit for ReadDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for ReadDemo { const MAX: usize = 4; } @@ -545,7 +824,10 @@ mod refusal { FamilyAdmission, FamilyAdmissionCoverage, FamilyShape, HandlingClass, LocalCauseKey, ReasonId, Refusal, RefusalFamily, RefusalFamilyId, StopBound, admit_order, admit_shape, }; - use crate::types::{BoundedConstruction, Limit, NonEmptyBounded, NonEmptyBoundedConstruction}; + use crate::types::{ + BoundedConstruction, DeclaredMagnitude, Limit, NonEmptyBounded, + NonEmptyBoundedConstruction, UnstatedMagnitude, + }; struct DemoSingle; impl RefusalFamily for DemoSingle { @@ -843,7 +1125,9 @@ mod refusal { fn issue_collections_are_nonempty_bounded() { struct DemoIssue; struct IssueLimit; - impl Limit for IssueLimit {} + impl Limit for IssueLimit { + type Authority = UnstatedMagnitude; + } let shape: Option)> = Some(drop); assert!(shape.is_some()); } @@ -927,7 +1211,9 @@ mod refusal { use crate::refusal::AdmittedPrefix; use crate::types::{ConstLimit, PositiveLimit, RootLawsProfile}; struct PostureDemo; - impl Limit for PostureDemo {} + impl Limit for PostureDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for PostureDemo { const MAX: usize = 3; } @@ -1023,7 +1309,9 @@ mod refusal { use crate::refusal::AdmittedPrefix; use crate::types::{ConstLimit, PositiveLimit, RootLawsProfile}; struct HaltDemo; - impl Limit for HaltDemo {} + impl Limit for HaltDemo { + type Authority = DeclaredMagnitude; + } impl ConstLimit for HaltDemo { const MAX: usize = 3; } @@ -1565,13 +1853,23 @@ mod identity { } /// The scope this home's demo stamp is instantiated over. + /// + /// `pub(crate)` for the reason the guard below is: the stamped guard's road + /// in names this type in its signature, so the scope reaches exactly as far + /// as the guard it scopes and never one step less. #[derive(Debug, Clone, PartialEq, Eq, Hash)] - struct DemoStampScope(u8); + pub(crate) struct DemoStampScope(u8); crate::scope_guard_version! { /// The stamped demo scope-guard version — written by the declarative /// stamp from one explicit typed invocation, not by hand. - struct StampedDemoVersion over DemoStampScope; + /// + /// Stamped `pub(crate)` rather than bare: the stamp seats the newtype in + /// a module of its own, so a guard with no visibility at all would be + /// sealed inside a module this surface cannot name. `pub(crate)` inside + /// this proof surface's private, test-gated module is the reach a bare + /// private guard already had, spelled where the seat now sits. + pub(crate) struct StampedDemoVersion over DemoStampScope, seated in mod stamped_demo_version; } /// The hand-written twin of what the stamp writes, authored the way every @@ -1701,7 +1999,7 @@ mod identity { mod value { use super::pairwise_distinct; - use crate::types::Limit; + use crate::types::{Limit, UnstatedMagnitude}; use crate::value::{ Absence, BoundedText, CANONICAL_INBOUND_PATH, InboundStage, LossyOperation, PRE_AUTHORITY_LADDER, PreAuthorityCheck, @@ -1818,9 +2116,13 @@ mod value { #[test] fn bounded_text_carries_its_limit_family() { struct PathLimit; - impl Limit for PathLimit {} + impl Limit for PathLimit { + type Authority = UnstatedMagnitude; + } struct LabelLimit; - impl Limit for LabelLimit {} + impl Limit for LabelLimit { + type Authority = UnstatedMagnitude; + } let over_path: Option)> = Some(drop); let over_label: Option)> = Some(drop); assert!(over_path.is_some()); @@ -5771,7 +6073,8 @@ mod security { TRUST_BOUNDARY_MEMBERS, }; use crate::types::{ - EvidenceRef, LimitWitness, NonEmptyBounded, ReferentAvailability, ReferentIntegrity, + EvidenceRef, LimitWitness, NonEmptyBounded, PositiveLimitWitness, ReferentAvailability, + ReferentIntegrity, }; fn demo_evidence(seed: u8) -> EvidenceRef { @@ -5857,7 +6160,8 @@ mod security { status: ShredRowStatus::LegallyRetained, }, vec![], - &LimitWitness::declared(16), + &PositiveLimitWitness::inhabited(LimitWitness::declared(16)) + .unwrap_or_else(|_| unreachable!("sixteen admits an item")), ) .unwrap_or_else(|_| unreachable!("one fits")), durability: demo_evidence(201), diff --git a/src/types.rs b/src/types.rs index fb64c3f..5f509dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -58,20 +58,163 @@ use core::marker::PhantomData; // supplied either at compile time (`ConstLimit`) or by a schema-minted witness. // --------------------------------------------------------------------------- +/// Which authority supplies one limit family's capacity. +/// +/// A capacity arrives by exactly one road, and which road is a fact about the +/// FAMILY rather than about any call site. [`DeclaredMagnitude`] is a number +/// written in the source; [`EvidenceSelectedMagnitude`] is a number the owner's +/// evidence selects while the machine runs; [`UnstatedMagnitude`] is a family +/// that has named neither. +/// +/// Every marker implementing this is uninhabited. Nothing constructs one, +/// nothing carries one, and none of them is a value: the whole of what a marker +/// does is be the type one family's [`Limit::Authority`] resolves to, so that +/// two ladders demanding different types cannot both be satisfied by one +/// family. +/// +/// # It is not sealed, and what that does and does not admit +/// +/// [`Limit`] is an extension point by decision — any home, and any frontend +/// outside this crate, declares a family — so the authority set is open in the +/// same way. What a marker declared outside can do is exactly nothing: +/// [`ConstLimit`] and [`EvidenceSelectedLimit`] name their authority type +/// EXACTLY, so a family whose authority is a fourth marker satisfies neither +/// ladder and reaches no mint. An open set therefore admits no capacity road +/// this crate did not declare; it admits only a family saying, in a vocabulary +/// of its own, that it takes none of the roads seated here. +pub trait CapacityAuthority {} + +/// The authority of a magnitude written in the SOURCE. +/// +/// [`ConstLimit`] is the declaration that supplies the number, and the two +/// compile-time roads — [`AdmittedLimit`] and [`PositiveLimit`] — are what stand +/// it under a plane's ceiling and prove it admits an item, both before the +/// program runs. +pub enum DeclaredMagnitude {} + +impl CapacityAuthority for DeclaredMagnitude {} + +/// The authority of a magnitude the owner's EVIDENCE selects while the machine +/// runs. +/// +/// [`EvidenceSelectedLimit`] is the declaration that admits this road, and the +/// two runtime roads — [`LimitWitness`] and [`PositiveLimitWitness`] — are what +/// carry the selection and the promise that it admits an item, because no +/// `const` block can see a number that does not exist yet. +pub enum EvidenceSelectedMagnitude {} + +impl CapacityAuthority for EvidenceSelectedMagnitude {} + +/// The authority of a family that has named NEITHER road. +/// +/// It is a real state and the largest one in this crate today: a family +/// bounding a [`Bounded`] seat needs no magnitude to exist, because +/// [`Bounded::empty`] reads none and an empty collection under such a family is +/// honest rather than degenerate. +/// +/// # What declaring it claims, exactly +/// +/// That the family has supplied no capacity authority the type system carries. +/// It does NOT claim the owner has no view about where the magnitude should +/// come from: several families in this crate say "schema-witnessed" in the prose +/// beside their declaration and say it nowhere a road can read, and this marker +/// is what makes that gap a fact rather than an absence — the family is on no +/// ladder, no mint takes it, and moving it onto one is a change to this line at +/// the declaration rather than a bound somebody remembers to add. +pub enum UnstatedMagnitude {} + +impl CapacityAuthority for UnstatedMagnitude {} + /// A limit family marker. The type names *which* limit governs a bounded value, so /// two different limits never unify: `Bounded` and /// `Bounded` are distinct types regardless of their magnitudes. /// /// Owner homes declare their limit families; the schema home is the only authority /// that mints runtime magnitudes (as [`LimitWitness`] values). -pub trait Limit {} +pub trait Limit { + /// Which authority supplies this family's capacity. + /// + /// # One family, one capacity authority, by type identity + /// + /// A family declares this once, at its own declaration, and the two ladder + /// traits name the authority they require EXACTLY: + /// [`ConstLimit`] requires [`DeclaredMagnitude`] and + /// [`EvidenceSelectedLimit`] requires [`EvidenceSelectedMagnitude`]. An + /// associated type resolves to one type, so a family declaring both ladders + /// is asking one projection to be two types at once and does not compile. + /// + /// That is the whole of the mechanism, and it is why nothing below has to + /// remember it: the exclusion is not a bound a road carries, a law that + /// asserts it, or a sentence in a doc comment — it is the arity of an + /// associated type. Two authorities for one capacity is the shape where two + /// independently supplied halves of one fact drift apart, and here the + /// second half cannot be supplied. + /// + /// # What it does not decide + /// + /// It does not supply a magnitude, and it does not check one. Declaring + /// [`DeclaredMagnitude`] without implementing [`ConstLimit`] leaves a family + /// with no `MAX` and therefore no road to [`AdmittedLimit`]; declaring + /// [`EvidenceSelectedMagnitude`] without implementing + /// [`EvidenceSelectedLimit`] leaves it with no road to + /// [`PositiveLimitWitness`]. Both are inert rather than wrong: the family + /// names a road and never walks it, and the ladder traits stay the one place + /// a capacity is actually reachable from. + type Authority: CapacityAuthority; +} /// A limit family whose magnitude is known at compile time. -pub trait ConstLimit: Limit { +/// +/// The supertrait bound names the authority exactly, so implementing this for a +/// family whose [`Limit::Authority`] is anything else is a type mismatch at the +/// declaration — see [`Limit::Authority`] for what that forecloses. +pub trait ConstLimit: Limit { /// The maximum item count this family admits. const MAX: usize; } +/// A limit family whose magnitude is SELECTED BY EVIDENCE while the machine +/// runs, rather than declared in the source. +/// +/// # Why a family says which ladder its magnitude travels +/// +/// [`ConstLimit`] and this trait are the two roads a family's capacity can +/// arrive by, and they establish their facts in different places. A declared +/// magnitude is a number in the source: [`AdmittedLimit`] stands it under a +/// plane's ceiling and [`PositiveLimit`] proves it admits an item, both before +/// the program runs. An evidence-selected magnitude does not exist until the +/// owner's evidence selects it, so no `const` block can see it, no ceiling +/// comparison can be settled at compile time, and the same two facts have to be +/// established by values instead — [`LimitWitness`] and [`PositiveLimitWitness`]. +/// +/// Several families in this crate said "a declared finite bound, +/// evidence-selected" in PROSE beside their declaration and said it nowhere a +/// road could read, which left the second ladder with a magnitude nothing +/// carried and a positivity nothing established. This trait is where that +/// sentence becomes a fact the compiler carries: [`PositiveLimitWitness`]'s mint +/// is bounded on it, so a family that never declared its magnitude +/// evidence-selected has no road to a runtime capacity at all. +/// +/// # What implementing it claims, and what it does not +/// +/// It claims exactly that the family's magnitude arrives at runtime and that the +/// owner admits the second ladder for it. It claims nothing about what that +/// magnitude will be and nothing about whether the number the evidence selects +/// is the right one for the family's domain — the owner profile and the evidence +/// select that, no road can check it, and no witness below pretends to. +/// +/// # A family cannot declare both ladders +/// +/// It once said here that a family implementing both this and [`ConstLimit`] +/// would be stating two authorities for one capacity, and that no bound could +/// see it. The supertrait bounds are what see it now: this trait requires +/// [`EvidenceSelectedMagnitude`] and [`ConstLimit`] requires +/// [`DeclaredMagnitude`], one associated type resolves to one type, and the +/// second implementation is a type mismatch at the declaration rather than a +/// defect a reader has to notice. [`Limit::Authority`] carries the whole +/// statement; nothing here restates it. +pub trait EvidenceSelectedLimit: Limit {} + /// The ceiling one PLANE admits its declared magnitudes under. /// /// # Root owns the algebra; a profile owns the number @@ -299,6 +442,36 @@ impl PositiveLimit { /// A runtime magnitude for the limit family `L`, minted only by schema validation. /// Carrying the family as a type parameter keeps runtime-limited and compile-limited /// values in the same shape without confusing their authorities. +/// +/// # What it establishes, and what it deliberately does not +/// +/// It establishes that schema validation selected this magnitude FOR THIS +/// FAMILY: the family tag is a type parameter, so one family's witnessed +/// magnitude never authorizes another's seat whatever the two numbers are. +/// +/// It does NOT establish that the family admits an item, and the absence is +/// [`AdmittedLimit`]'s exactly. A witnessed magnitude of zero is an honest +/// selection for a seat that holds nothing: [`Bounded::admitted`] under it +/// yields a real empty collection, and a base witness that refused the +/// selection would refuse that seat with it. The positivity claim is seated one +/// witness up, in [`PositiveLimitWitness`], where exactly the runtime roads +/// promising an inhabitant consume it. +/// +/// # No production road mints one, and that is stated rather than implied +/// +/// `LimitWitness::declared` is the only mint, it is `#[cfg(test)]`, and it is +/// crate-internal. So production schema validation can neither mint a runtime +/// magnitude for any family today nor consume one, and nothing downstream can +/// either. What stands is the ALGEBRA — which witnesses exist, what each one +/// establishes, which road takes which, and the declaration-side guard that +/// keeps a family off a ladder it never admitted. A production road is a +/// separate opening whose exact condition is the schema home carrying a +/// validation path that selects a magnitude; until then every claim about this +/// witness is a claim about the shape, and none is a claim about a running +/// machine. +/// +/// The mint is named in backticks above rather than linked, because a +/// documentation build does not contain it. #[must_use = "a limit witness is the magnitude schema validation established; dropping it \ discards the only admitted bound for its family"] pub struct LimitWitness { @@ -324,6 +497,139 @@ impl LimitWitness { } } +/// How admitting one evidence-selected magnitude as a capacity refuses. +/// +/// Single cause, because there is exactly one question to ask: a magnitude that +/// admits an item has nothing left to fail. A plain root enum — the +/// refusal-family binding is implemented by the refusal home, pointing downward. +#[must_use = "a refusal carries the lawful reason the admission did not proceed"] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CapacityAdmission { + /// The witnessed magnitude admits no item at all. + NotInhabited, +} + +/// Evidence that one limit family's EVIDENCE-SELECTED magnitude admits at least +/// one item. +/// +/// # The runtime rung of the ladder [`PositiveLimit`] holds at compile time +/// +/// The two witnesses answer the same question about magnitudes that arrive by +/// different roads, and neither is evidence for the other's road. +/// [`PositiveLimit`] proves an inhabitant off a number in the source, under a +/// named plane's ceiling, before the program runs. This one proves an inhabitant +/// off a number the owner's evidence selected while the machine ran, where there +/// is no `L::MAX` to admit and no profile that could admit it. So the roads that +/// promise an inhabitant have a positivity witness whichever road their +/// magnitude arrived by, and the road that had neither — +/// [`NonEmptyBounded::admitted`] — was the one hole in that ladder. +/// +/// # What it establishes +/// +/// Three facts, and holding the value is the whole of the evidence: +/// +/// 1. **The family.** `L` is carried in the contained witness's own type +/// parameter, so a capacity admitted for one family does not typecheck where +/// another's is required — whatever the two magnitudes are. +/// 2. **A positive capacity.** The magnitude admits at least one item, which is +/// what a signature promising a first item needs and what a zero magnitude +/// can never supply. +/// 3. **The admitted runtime maximum.** [`max`](Self::max) is the magnitude +/// schema validation selected, read off the contained witness. +/// +/// # The claim ceiling, exactly +/// +/// It establishes nothing about whether the magnitude is the RIGHT one for its +/// domain. That is not a fact this witness withholds for lack of evidence; it is +/// a fact no road here could ever hold, because the number is the owner +/// profile's and the evidence's to select, and neither is visible from a witness +/// that only sees a count. Reading a held witness as "this capacity is +/// appropriate" reads a claim nobody made. It establishes nothing about any +/// ceiling either: no plane admitted this magnitude, because a magnitude that +/// does not exist until runtime has nothing to compare against a declared +/// ceiling at the time a ceiling comparison would have to be settled. +/// +/// # The stronger witness CONTAINS the weaker one +/// +/// [`PositiveLimit`]'s containment doctrine, on the runtime road. The selection +/// fact is not restated here — it is carried. This type's one field is the +/// [`LimitWitness`] schema validation minted, and the magnitude this witness +/// reports is that witness's own, so "the positive witness is the stronger form +/// of the base one" is a fact about this value's shape rather than an agreement +/// between two numbers somebody has to keep in step. Containment is not a +/// conversion: the contained witness is private, no accessor hands it out, and +/// there is no road from here back to a bare [`LimitWitness`]. +/// +/// # Nothing consumes one yet, and that is two absences rather than one +/// +/// [`NonEmptyBounded::admitted`] is the one road that takes this witness, and +/// both ends of it are still shut. +/// +/// Upstream, no value of it can be built: [`LimitWitness`] has only its +/// `#[cfg(test)]` mint, so there is no production road to the selection this +/// witness is the stronger form of. +/// +/// Downstream, the collection-shaped refusal bodies cannot reach it at all. The +/// mints on [`crate::refusal::AdmittedPrefix`] are bounded on [`ConstLimit`], +/// so a family on the runtime ladder — whose authority is +/// [`EvidenceSelectedMagnitude`] and therefore never [`DeclaredMagnitude`] — +/// has no road into that package whatever witness it holds. Every +/// collection-shaped body in the machine seats an `AdmittedPrefix`, so a +/// runtime capacity is presently a witness with no consumer among them. The +/// exact opening condition is a prefix road that takes this witness; naming it +/// is not building it, and this paragraph claims only that the gap is known. +#[must_use = "a positive limit witness is the evidence a family's evidence-selected magnitude \ + admits an item; dropping it discards the only proof a runtime road promising an \ + inhabitant may act on"] +pub struct PositiveLimitWitness { + witness: LimitWitness, +} + +impl PositiveLimitWitness { + /// Admit one evidence-selected magnitude as a capacity that holds an item. + /// + /// The bound is [`EvidenceSelectedLimit`] rather than [`Limit`], and that is + /// the gate: a family whose owner never declared its magnitude + /// evidence-selected has no road here, so a runtime capacity cannot be + /// minted for a family that never admitted the runtime ladder. + /// + /// It takes the witness by value and keeps it. A road that borrowed the + /// selection and copied the number out would leave the caller holding a + /// second value carrying the same magnitude under weaker evidence, and the + /// two could then be handed to different seats. + /// + /// This road refuses rather than refusing to compile, and the reason is + /// structural rather than a preference: the magnitude does not exist until + /// the evidence selects it, so there is no value for a `const` block to + /// read. Where the same relation IS visible in the source it moves into the + /// declaration instead — [`PositiveLimit::inhabited_under_profile`]'s + /// `const` block is that seat, and a zero-maximum family stops there at + /// compile time rather than here at runtime. + /// + /// # Errors + /// + /// Returns [`CapacityAdmission::NotInhabited`] when the witnessed magnitude + /// admits no item at all. + pub fn inhabited(witness: LimitWitness) -> Result { + if witness.max() >= 1 { + Ok(Self { witness }) + } else { + Err(CapacityAdmission::NotInhabited) + } + } +} + +impl PositiveLimitWitness { + /// The witnessed maximum this witness carries; at least one by construction. + /// + /// Read off the contained base witness, so no second copy of the magnitude + /// stands here to disagree with the one schema validation selected. + #[must_use] + pub fn max(&self) -> usize { + self.witness.max() + } +} + /// The construction refusal for bounded collections. A plain root enum — the /// refusal-family binding is implemented by the refusal home, pointing downward. #[must_use = "a refusal carries the lawful reason the construction did not proceed"] @@ -695,14 +1001,31 @@ impl NonEmptyBounded { } impl NonEmptyBounded { - /// Checked construction against a schema-minted runtime witness. + /// Checked construction against a schema-minted runtime witness that admits + /// an item. /// - /// # The claim class: SCHEMA-MINTED RUNTIME MAGNITUDE + /// # The claim class: SCHEMA-MINTED RUNTIME MAGNITUDE, and it must be + /// inhabited + /// + /// [`Bounded::admitted`]'s magnitude authority, with + /// [`NonEmptyBounded::admitted_const`]'s evidence bar. No profile is + /// involved and none could be: the magnitude was selected by the owner's + /// evidence at runtime rather than declared at compile time, so there is no + /// `L::MAX` to admit and a profile-scoped admission is not evidence for this + /// road. + /// + /// It takes the STRONGER runtime witness, and the reason is its own + /// signature. It promises an inhabitant: whatever the runtime count turns + /// out to be, the value it returns holds a first item. A family whose + /// evidence selected a magnitude of zero can never lawfully satisfy that + /// promise — every call would refuse — so a bare [`LimitWitness`], which + /// admits a zero selection on purpose for [`Bounded::admitted`]'s + /// empty-only seat, is not enough evidence here. /// - /// [`Bounded::admitted`]'s exactly, and the inhabitant is supplied by the - /// signature rather than by evidence: the first item is a separate - /// parameter, so emptiness is unrepresentable here whatever the witnessed - /// magnitude turns out to be. + /// The first item being a separate parameter makes emptiness + /// unrepresentable in the RESULT; it says nothing about the magnitude the + /// road compares against, which is why the promise still needs the evidence + /// and is not discharged by the shape. /// /// # Errors /// @@ -711,7 +1034,7 @@ impl NonEmptyBounded { pub fn admitted( first: T, rest: Vec, - witness: &LimitWitness, + witness: &PositiveLimitWitness, ) -> Result { if rest.len().saturating_add(1) <= witness.max() { Ok(Self { diff --git a/testpak/README.md b/testpak/README.md index 8412d43..8c812e4 100644 --- a/testpak/README.md +++ b/testpak/README.md @@ -146,7 +146,7 @@ happened here, and both read as coverage until somebody compares the two. | `a-materialized-malformed-mutant.rs` | lane C's `MalformedRust` seat: the mutated artifact text, checked in with its provenance stated, does not compile | | `a-past-ceiling-family-cannot-mint-a-positive-limit.rs` | the stronger witness refuses a past-ceiling family with the BASE witness's own diagnostic, because the base mint is what ran — which is what makes the composition falsifiable rather than a restated assertion | | `a-post-proof-join-outside-the-closure.rs` | joining the rendered units is crate-internal with one caller — the proof — so there is no public road to a joined tree outside it | -| `a-production-scope-guard-cannot-be-laundered.rs` | representation privacy on a guard the machine SHIPS, not only on roles a fixture stamps for itself: from outside the crate, `FrameVersion(position)` and `version.0` each refuse on their own. That the guard has no road OUT is not this file's claim and cannot be — see its header, and `cargo xtask check`'s `stamped-guards-seal-their-position` | +| `a-production-scope-guard-cannot-be-laundered.rs` | representation privacy on a guard the machine SHIPS, not only on roles a fixture stamps for itself: from outside the crate, `FrameVersion(position)` and `version.0` each refuse on their own. That the guard has no road OUT is not this file's claim and cannot be — it is structural instead: the stamp seats the guard in a module it writes whole, so the complete set of roads out is the expansion and rustc refuses every other one | | `a-related-set-assembled-from-two-levels.rs` | a related set is built from issue MATERIAL and never from identities somebody already derived: the road taking a whole-body commitment beside a set of per-issue identities is not expressible | | `a-related-set-married-to-another-completion.rs` | a completion belongs to the set it was built beside: the seats are private, so the cross-wired literal does not compile | | `a-remainder-married-to-another-body.rs` | a completion belongs to the body it was minted with: the seats are private, so the cross-wired literal does not compile | diff --git a/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs b/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs new file mode 100644 index 0000000..18c2d84 --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs @@ -0,0 +1,64 @@ +//! The reversal for the runtime ladder's gate: a family whose owner never +//! declared its magnitude evidence-selected has no road to a runtime capacity. +//! +//! The two families below differ in exactly one line. Both are limit families; +//! one declares `EvidenceSelectedLimit` and the other does not, and nothing else +//! about them differs — same shape, same absence of a compile-time magnitude, +//! same seat. So the refusal below can only be the missing declaration, and the +//! lawful half above it is what says the bound is satisfiable at all. +//! +//! Nothing is minted here either. The bound sits on the mint, so naming the mint +//! as a value is enough to make the compiler settle it, and a fixture outside +//! this crate has no `LimitWitness` to build in any case. +//! +//! # The recorded diagnostic carries a population nobody wrote +//! +//! `rustc` answers an unsatisfied bound by listing the types that DO satisfy it, +//! and that list is derived from the impls rather than from anything authored +//! here. So the committed `.stderr` beside this file carries the roster of every +//! family on the runtime ladder, and a family joining or leaving it moves this +//! snapshot and fails this test. It is a drift detector rather than a count: the +//! compiler shortens the list past a threshold of its own, so the exact roster is +//! readable only while it is short, and a repository join deriving the population +//! from the sources is still owed. + +use threadpak::types::{ + CapacityAdmission, EvidenceSelectedLimit, EvidenceSelectedMagnitude, Limit, LimitWitness, + PositiveLimitWitness, UnstatedMagnitude, +}; + +/// A family whose owner declared the magnitude evidence-selected. +struct DeclaredFamily; + +impl Limit for DeclaredFamily { + type Authority = EvidenceSelectedMagnitude; +} + +impl EvidenceSelectedLimit for DeclaredFamily {} + +/// A family whose owner did not. It is a lawful limit family and bounds seats +/// like any other; what it has not done is admit the runtime ladder. +struct UndeclaredFamily; + +impl Limit for UndeclaredFamily { + type Authority = UnstatedMagnitude; +} + +/// The lawful half, and it must stay lawful: the declared family reaches the +/// mint. +const DECLARED: fn( + LimitWitness, +) -> Result, CapacityAdmission> = + PositiveLimitWitness::inhabited; + +/// The unlawful half: the same mint, named for a family that never declared the +/// ladder it belongs to. +const UNDECLARED: fn( + LimitWitness, +) -> Result, CapacityAdmission> = + PositiveLimitWitness::inhabited; + +fn main() { + let _ = DECLARED; + let _ = UNDECLARED; +} diff --git a/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.stderr b/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.stderr new file mode 100644 index 0000000..fbe4230 --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.stderr @@ -0,0 +1,29 @@ +error[E0277]: the trait bound `UndeclaredFamily: EvidenceSelectedLimit` is not satisfied + --> tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs:59:5 + | +59 | PositiveLimitWitness::inhabited; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `EvidenceSelectedLimit` is not implemented for `UndeclaredFamily` + --> tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs:41:1 + | +41 | struct UndeclaredFamily; + | ^^^^^^^^^^^^^^^^^^^^^^^ + = help: the following other types implement trait `EvidenceSelectedLimit`: + AdmissionIssueLimit + AuthoredNameIssueLimit + ClosureNamespaceIssueLimit + DeclaredFamily + EffectBatchIssueLimit + ExecutionFormIssueLimit + LinkResolutionIssueLimit + SemanticFormIssueLimit + ShredParticipantLimit +note: required by a bound in `PositiveLimitWitness::::inhabited` + --> $WORKSPACE/src/types.rs + | + | impl PositiveLimitWitness { + | ^^^^^^^^^^^^^^^^^^^^^ required by this bound in `PositiveLimitWitness::::inhabited` +... + | pub fn inhabited(witness: LimitWitness) -> Result { + | --------- required by a bound in this associated function diff --git a/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs new file mode 100644 index 0000000..aa37649 --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs @@ -0,0 +1,56 @@ +//! The reversal for the capacity witness's family seat: one family's runtime +//! capacity is not another's, whatever the two magnitudes are. +//! +//! Both families below declare their magnitude evidence-selected, so the +//! declaration gate is satisfied on both sides and cannot be what stops this. +//! Both would carry the same number. What refuses is the seat: the family rides +//! on the witness's own type parameter, so a capacity admitted for one family +//! does not typecheck where the other's is required. +//! +//! No mint appears anywhere below. That is deliberate rather than a shortcut: +//! the claim under judgement is about the TYPES, and driving it through a +//! constructor would risk the refusal coming from the construction instead. It +//! is also the only road available from outside the crate — `LimitWitness` has +//! no public mint until the schema home carries the real declaration path — and +//! this fixture is written to need none. + +use threadpak::types::{ + EvidenceSelectedLimit, EvidenceSelectedMagnitude, Limit, PositiveLimitWitness, +}; + +/// One family whose magnitude the owner's evidence selects. +struct FirstFamily; + +impl Limit for FirstFamily { + type Authority = EvidenceSelectedMagnitude; +} + +impl EvidenceSelectedLimit for FirstFamily {} + +/// A second family on the same ladder, so the declaration is not the difference. +struct SecondFamily; + +impl Limit for SecondFamily { + type Authority = EvidenceSelectedMagnitude; +} + +impl EvidenceSelectedLimit for SecondFamily {} + +/// The consumer, naming exactly which family's capacity it will act on. +fn admits_the_first(_capacity: &PositiveLimitWitness) {} + +/// The lawful half, and it must stay lawful: the consumer takes its own +/// family's capacity. +fn lawful(capacity: &PositiveLimitWitness) { + admits_the_first(capacity); +} + +/// The unlawful half: the other family's capacity handed to the same consumer. +fn crossed(capacity: &PositiveLimitWitness) { + admits_the_first(capacity); +} + +fn main() { + let _ = lawful; + let _ = crossed; +} diff --git a/testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr new file mode 100644 index 0000000..58eaee6 --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr @@ -0,0 +1,15 @@ +error[E0308]: mismatched types + --> tests/compile-fail/a-capacity-witness-from-another-family.rs:50:22 + | +50 | admits_the_first(capacity); + | ---------------- ^^^^^^^^ expected `&PositiveLimitWitness`, found `&PositiveLimitWitness` + | | + | arguments to this function are incorrect + | + = note: expected reference `&PositiveLimitWitness` + found reference `&PositiveLimitWitness` +note: function defined here + --> tests/compile-fail/a-capacity-witness-from-another-family.rs:40:4 + | +40 | fn admits_the_first(_capacity: &PositiveLimitWitness) {} + | ^^^^^^^^^^^^^^^^ --------------------------------------------- diff --git a/testpak/tests/compile-fail/a-cross-profile-admission.rs b/testpak/tests/compile-fail/a-cross-profile-admission.rs index 459cdaf..b326dae 100644 --- a/testpak/tests/compile-fail/a-cross-profile-admission.rs +++ b/testpak/tests/compile-fail/a-cross-profile-admission.rs @@ -11,7 +11,9 @@ //! is small enough for both planes; what refuses is not the number but the //! claim, and the claim is the whole content of the witness. -use threadpak::types::{AdmittedLimit, ConstLimit, Limit, LimitAdmissionProfile}; +use threadpak::types::{ + AdmittedLimit, ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile, +}; use threadpak_macroc::AuthoringLimitProfile; /// A second plane's ceiling, declared here because this file is the plane @@ -25,7 +27,9 @@ impl LimitAdmissionProfile for ForeignProfile { /// A family well inside both ceilings. struct SmallFamily; -impl Limit for SmallFamily {} +impl Limit for SmallFamily { + type Authority = DeclaredMagnitude; +} impl ConstLimit for SmallFamily { const MAX: usize = 8; diff --git a/testpak/tests/compile-fail/a-cross-profile-admission.stderr b/testpak/tests/compile-fail/a-cross-profile-admission.stderr index a9aab4e..4bd51b7 100644 --- a/testpak/tests/compile-fail/a-cross-profile-admission.stderr +++ b/testpak/tests/compile-fail/a-cross-profile-admission.stderr @@ -1,7 +1,7 @@ error[E0308]: mismatched types - --> tests/compile-fail/a-cross-profile-admission.rs:41:34 + --> tests/compile-fail/a-cross-profile-admission.rs:45:34 | -41 | seats_an_authoring_admission(&ELSEWHERE); +45 | seats_an_authoring_admission(&ELSEWHERE); | ---------------------------- ^^^^^^^^^^ expected `AuthoringLimitProfile`, found `ForeignProfile` | | | arguments to this function are incorrect @@ -9,7 +9,7 @@ error[E0308]: mismatched types = note: expected reference `&AdmittedLimit` found reference `&AdmittedLimit` note: function defined here - --> tests/compile-fail/a-cross-profile-admission.rs:38:4 + --> tests/compile-fail/a-cross-profile-admission.rs:42:4 | -38 | fn seats_an_authoring_admission(_: &AdmittedLimit) {} +42 | fn seats_an_authoring_admission(_: &AdmittedLimit) {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ----------------------------------------------------- diff --git a/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs b/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs new file mode 100644 index 0000000..ac0e68b --- /dev/null +++ b/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs @@ -0,0 +1,56 @@ +//! The reversal for the capacity-authority split: one family cannot declare two +//! roads to one capacity. +//! +//! A declared magnitude and an evidence-selected one are two authorities over +//! the same fact — how many items this family admits — and a family claiming +//! both leaves the machine with two answers and no rule for choosing between +//! them. That defect used to be a sentence in `crate::types`: the doc comment +//! said such a family would state two authorities and that no bound could see +//! it. A documented impossibility the type system permits is the two-halves +//! shape this repository exists to refuse, so the sentence is now the arity of +//! an associated type. +//! +//! The two families below differ in exactly one line. Both declare +//! `DeclaredMagnitude` as their authority and both supply a compile-time +//! maximum; one stops there and the other adds the runtime ladder. So the +//! refusal below can only be the second authority, and the lawful half above it +//! is what says the declaration it repeats is satisfiable at all. +//! +//! Nothing is minted here. The exclusion lives at the DECLARATION rather than at +//! any road, so writing the declaration is enough to settle it and no witness, +//! profile, or collection has to be built to reach the refusal. + +use threadpak::types::{ConstLimit, DeclaredMagnitude, EvidenceSelectedLimit, Limit}; + +/// A family declaring exactly one capacity authority, and the ladder that +/// authority admits. +struct OneAuthority; + +impl Limit for OneAuthority { + type Authority = DeclaredMagnitude; +} + +impl ConstLimit for OneAuthority { + const MAX: usize = 8; +} + +/// The same declaration again, so that what differs below is the second ladder +/// and nothing else. +struct TwoAuthorities; + +impl Limit for TwoAuthorities { + type Authority = DeclaredMagnitude; +} + +impl ConstLimit for TwoAuthorities { + const MAX: usize = 8; +} + +/// The unlawful half: the runtime ladder, declared for a family whose authority +/// is already the compile-time one. +impl EvidenceSelectedLimit for TwoAuthorities {} + +fn main() { + let _ = OneAuthority::MAX; + let _ = TwoAuthorities::MAX; +} diff --git a/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.stderr b/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.stderr new file mode 100644 index 0000000..1192697 --- /dev/null +++ b/testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.stderr @@ -0,0 +1,16 @@ +error[E0271]: type mismatch resolving `::Authority == EvidenceSelectedMagnitude` + --> tests/compile-fail/a-family-declaring-both-capacity-authorities.rs:51:32 + | +51 | impl EvidenceSelectedLimit for TwoAuthorities {} + | ^^^^^^^^^^^^^^ type mismatch resolving `::Authority == EvidenceSelectedMagnitude` + | +note: expected this to be `EvidenceSelectedMagnitude` + --> tests/compile-fail/a-family-declaring-both-capacity-authorities.rs:42:22 + | +42 | type Authority = DeclaredMagnitude; + | ^^^^^^^^^^^^^^^^^ +note: required by a bound in `EvidenceSelectedLimit` + --> $WORKSPACE/src/types.rs + | + | pub trait EvidenceSelectedLimit: Limit {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EvidenceSelectedLimit` diff --git a/testpak/tests/compile-fail/a-magnitude-past-the-authoring-ceiling.rs b/testpak/tests/compile-fail/a-magnitude-past-the-authoring-ceiling.rs index 648f99d..e9d9533 100644 --- a/testpak/tests/compile-fail/a-magnitude-past-the-authoring-ceiling.rs +++ b/testpak/tests/compile-fail/a-magnitude-past-the-authoring-ceiling.rs @@ -9,12 +9,16 @@ //! declaring a magnitude past the AUTHORING profile's ceiling stops the compiler //! during const evaluation, so no artifact carrying it is ever produced. -use threadpak::types::{AdmittedLimit, ConstLimit, Limit, LimitAdmissionProfile}; +use threadpak::types::{ + AdmittedLimit, ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile, +}; use threadpak_macroc::AuthoringLimitProfile; struct PastTheCeiling; -impl Limit for PastTheCeiling {} +impl Limit for PastTheCeiling { + type Authority = DeclaredMagnitude; +} impl ConstLimit for PastTheCeiling { const MAX: usize = AuthoringLimitProfile::MAX_DECLARED_LIMIT + 1; diff --git a/testpak/tests/compile-fail/a-past-ceiling-family-cannot-mint-a-positive-limit.rs b/testpak/tests/compile-fail/a-past-ceiling-family-cannot-mint-a-positive-limit.rs index dcc301e..173224a 100644 --- a/testpak/tests/compile-fail/a-past-ceiling-family-cannot-mint-a-positive-limit.rs +++ b/testpak/tests/compile-fail/a-past-ceiling-family-cannot-mint-a-positive-limit.rs @@ -13,7 +13,9 @@ //! failing to compile — and would say nothing about whether the two roads still //! agree, which is the defect the composition closes. -use threadpak::types::{ConstLimit, Limit, LimitAdmissionProfile, PositiveLimit}; +use threadpak::types::{ + ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile, PositiveLimit, +}; /// The qualification plane's own admitting ceiling, declared here because this /// is the plane doing the admitting. @@ -27,7 +29,9 @@ impl LimitAdmissionProfile for QualificationProfile { /// the only fact left to fail — and declares a magnitude past the ceiling. struct PastTheCeiling; -impl Limit for PastTheCeiling {} +impl Limit for PastTheCeiling { + type Authority = DeclaredMagnitude; +} impl ConstLimit for PastTheCeiling { const MAX: usize = 65; diff --git a/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs b/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs index d7059d5..db0159c 100644 --- a/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs +++ b/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs @@ -28,11 +28,13 @@ //! green, and the whole compile-refusal suite with it. //! //! *No road out exists* is not a sentence Rust can be asked to refuse, so it is -//! not asked here. `cargo xtask check`'s `stamped-guards-seal-their-position` -//! derives it — over every type the stamp is invoked for, and over the stamp's -//! own transcriber, where one accessor would unseal all of them at once. The -//! count that gate prints is the population; no number stands in this file, -//! because a count kept beside a derivation is a second thing to keep true. +//! not asked here. It is not asked of a repository law either, any more: the +//! stamp seats each guard in a module of its own — `seated in mod +//! frame_version` — and emits the newtype, its road in, and its one comparison +//! into it. Nothing hand-written can be added to a module that exists only +//! inside an expansion, so the complete set of roads out of a stamped guard IS +//! the transcriber, and every other road is a compiler refusal rather than a +//! finding. //! //! No value is constructed and none could be: `AuthorityPosition::assigned` is //! the authority-side mint and `ReferenceFrameId` has no outside road either, so diff --git a/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.stderr b/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.stderr index cfe5a0b..88ede11 100644 --- a/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.stderr +++ b/testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.stderr @@ -1,14 +1,14 @@ error[E0423]: cannot initialize a tuple struct which contains private fields - --> tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs:51:20 + --> tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs:53:20 | -51 | |position| threadpak::navigation::FrameVersion(position); +53 | |position| threadpak::navigation::FrameVersion(position); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: constructor is not visible here due to private fields --> $WORKSPACE/src/02_identity/mod.rs | - | $vis struct $name($crate::identity::AuthorityPosition<$scope>); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private field + | $vis struct $name($crate::identity::AuthorityPosition<$scope>); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private field | ::: $WORKSPACE/src/11_navigation/types.rs | @@ -17,17 +17,17 @@ note: constructor is not visible here due to private fields | | /// frame is a VALUE inside the position rather than a type parameter, so two | | /// frames' versions are ONE type and the compiler never tells them apart: ... | - | | pub struct FrameVersion over ReferenceFrameId; + | | pub struct FrameVersion over ReferenceFrameId, seated in mod frame_version; | | } | |_- in this macro invocation = note: this error originates in the macro `crate::scope_guard_version` (in Nightly builds, run with -Z macro-backtrace for more info) help: you might have meant to use the `positioned` associated function | -51 | |position| threadpak::navigation::FrameVersion::positioned(position); +53 | |position| threadpak::navigation::FrameVersion::positioned(position); | ++++++++++++ error[E0616]: field `0` of struct `FrameVersion` is private - --> tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs:46:27 + --> tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs:48:27 | -46 | > = |version| version.0; +48 | > = |version| version.0; | ^ private field diff --git a/testpak/tests/compile-fail/a-remainder-married-to-another-body.rs b/testpak/tests/compile-fail/a-remainder-married-to-another-body.rs index 4bd9c39..e727507 100644 --- a/testpak/tests/compile-fail/a-remainder-married-to-another-body.rs +++ b/testpak/tests/compile-fail/a-remainder-married-to-another-body.rs @@ -36,7 +36,9 @@ //! which is exactly what this package does not have. use threadpak::refusal::{AdmittedPrefix, StopBound}; -use threadpak::types::{ConstLimit, Limit, LimitAdmissionProfile, PositiveLimit}; +use threadpak::types::{ + ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile, PositiveLimit, +}; /// This file's own plane, declared here because this file is the plane /// declaring it. @@ -52,7 +54,9 @@ impl LimitAdmissionProfile for FixtureProfile { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct IssueFamily; -impl Limit for IssueFamily {} +impl Limit for IssueFamily { + type Authority = DeclaredMagnitude; +} impl ConstLimit for IssueFamily { const MAX: usize = 3; diff --git a/testpak/tests/compile-fail/a-remainder-married-to-another-body.stderr b/testpak/tests/compile-fail/a-remainder-married-to-another-body.stderr index abe224b..48e39d6 100644 --- a/testpak/tests/compile-fail/a-remainder-married-to-another-body.stderr +++ b/testpak/tests/compile-fail/a-remainder-married-to-another-body.stderr @@ -1,9 +1,9 @@ error[E0451]: fields `carried` and `completion` of struct `AdmittedPrefix` are private - --> tests/compile-fail/a-remainder-married-to-another-body.rs:79:9 + --> tests/compile-fail/a-remainder-married-to-another-body.rs:83:9 | -78 | let _crossed = AdmittedPrefix { +82 | let _crossed = AdmittedPrefix { | -------------- in this type -79 | carried: dropped_none.carried().clone(), +83 | carried: dropped_none.carried().clone(), | ^^^^^^^ private field -80 | completion: dropped_four.completion(), +84 | completion: dropped_four.completion(), | ^^^^^^^^^^ private field diff --git a/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs b/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs index 42ec9bc..1d0ae22 100644 --- a/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs +++ b/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs @@ -19,12 +19,20 @@ //! //! It is not the universal statement, and it never could be: a list names the //! roads that exist, so a family added tomorrow with a public mint leaves this -//! file compiling exactly as it does now. The universal half is -//! `cargo xtask check`'s `refusal-mints-are-inside-the-plane`, which derives the -//! population rather than naming it — every services record whose every seat is -//! private and that some road refuses with, joined against every road that hands -//! one back — and refuses a reachable mint by naming both. Its denominators are -//! printed on every run. +//! file compiling exactly as it does now. A repository law used to try to close +//! that gap by deriving the population — every closed record some road refuses +//! with, joined against every road handing one back — and it was wrong +//! repeatedly, because answering it means resolving types, following aliases, +//! deciding what a receiver stands for, and inferring reachability. It is +//! deleted. +//! +//! What stands in its place is structural. Each body is DECLARED in a `seat` +//! module inside its home's `type_guard.rs`, and `cargo xtask check`'s +//! `seat-modules-carry-nothing-else` holds that module to carrying the record +//! and inherent implementations of it and nothing else — a question about item +//! kinds, resolving nothing. So the set of code that can reach a private seat is +//! a module read in one screen rather than a file of dozens of types, and every +//! road outside it is `E0451` or `E0616` from the compiler. //! //! The paths are REFERENCED rather than called on purpose. Privacy is settled at //! resolution, so a reference establishes the claim without constructing diff --git a/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.stderr b/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.stderr index 72ed231..9dec906 100644 --- a/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.stderr +++ b/testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.stderr @@ -1,118 +1,124 @@ error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:40:45 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:48:45 | -40 | let _planning_one = ProjectionPlanning::established; +48 | let _planning_one = ProjectionPlanning::established; | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/refusal/type_guard.rs | - | pub(crate) fn established(issue: ProjectionPlanningIssue) -> Self { - | ----------------------------------------------------------------- private associated function defined here + | pub(crate) fn established(issue: ProjectionPlanningIssue) -> Self { + | ----------------------------------------------------------------- private associated function defined here error[E0624]: associated function `co_established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:41:46 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:49:46 | -41 | let _planning_many = ProjectionPlanning::co_established; +49 | let _planning_many = ProjectionPlanning::co_established; | ^^^^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/refusal/type_guard.rs | - | / pub(crate) fn co_established( - | | first: ProjectionPlanningIssue, - | | rest: Vec, - | | ) -> Self { - | |_____________- private associated function defined here + | / pub(crate) fn co_established( + | | first: ProjectionPlanningIssue, + | | rest: Vec, + | | ) -> Self { + | |_________________- private associated function defined here error[E0624]: associated function `bound_exceeded` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:42:47 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:50:47 | -42 | let _planning_bound = ProjectionPlanning::bound_exceeded; +50 | let _planning_bound = ProjectionPlanning::bound_exceeded; | ^^^^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/refusal/type_guard.rs | - | pub(crate) fn bound_exceeded(axis: BoundAxis, bound: usize, observed: usize) -> Self { - | ------------------------------------------------------------------------------------ private associated function defined here + | pub(crate) fn bound_exceeded(axis: BoundAxis, bound: usize, observed: usize) -> Self { + | ------------------------------------------------------------------------------------ private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:43:47 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:51:47 | -43 | let _template_one = TemplateConstruction::established; +51 | let _template_one = TemplateConstruction::established; | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/template/type_guard.rs | - | fn established(issue: TemplateConstructionIssue) -> Self { - | -------------------------------------------------------- private associated function defined here + | pub(super) fn established(issue: TemplateConstructionIssue) -> Self { + | ------------------------------------------------------------------- private associated function defined here error[E0624]: associated function `co_established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:44:48 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:52:48 | -44 | let _template_many = TemplateConstruction::co_established; +52 | let _template_many = TemplateConstruction::co_established; | ^^^^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/template/type_guard.rs | - | / fn co_established( - | | first: TemplateConstructionIssue, - | | rest: Vec, - | | ) -> Self { - | |_____________- private associated function defined here + | / pub(super) fn co_established( + | | first: TemplateConstructionIssue, + | | rest: Vec, + | | ) -> Self { + | |_________________- private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:45:72 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:53:72 | -45 | let _closure = ProjectionClosureRefusal::::established; +53 | let _closure = ProjectionClosureRefusal::::established; | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/closure/type_guard.rs | - | fn established(first: ClosureIssue, rest: Vec>) -> Self { - | -------------------------------------------------------------------------- private associated function defined here + | pub(super) fn established(first: ClosureIssue, rest: Vec>) -> Self { + | ------------------------------------------------------------------------------------- private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:46:42 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:54:42 | -46 | let _coverage = ExplanationCoverage::established; - | ^^^^^^^^^^^ private associated function +54 | let _coverage = ExplanationCoverage::established; + | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/explanation_protocol/type_guard.rs | - | fn established(first: ExplanationCoverageIssue, rest: Vec) -> Self { - | -------------------------------------------------------------------------------------------- private associated function defined here + | / pub(super) fn established( + | | first: ExplanationCoverageIssue, + | | rest: Vec, + | | ) -> Self { + | |_________________- private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:47:49 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:55:49 | -47 | let _trigger_view = TriggerViewComposition::established; +55 | let _trigger_view = TriggerViewComposition::established; | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/trigger_view/type_guard.rs | - | fn established(first: TriggerViewIssue, rest: Vec) -> Self { - | ---------------------------------------------------------------------------- private associated function defined here + | pub(super) fn established(first: TriggerViewIssue, rest: Vec) -> Self { + | --------------------------------------------------------------------------------------- private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:48:52 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:56:52 | -48 | let _composition = CompositionRootDeclaration::established; - | ^^^^^^^^^^^ private associated function +56 | let _composition = CompositionRootDeclaration::established; + | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/composition/type_guard.rs | - | fn established(first: CompositionRootIssue, rest: Vec) -> Self { - | ------------------------------------------------------------------------------------ private associated function defined here + | / pub(super) fn established( + | | first: CompositionRootIssue, + | | rest: Vec, + | | ) -> Self { + | |_________________- private associated function defined here error[E0624]: associated function `established` is private - --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:49:42 + --> tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs:57:42 | -49 | let _capture = RefusalDeriveRefusal::established; +57 | let _capture = RefusalDeriveRefusal::established; | ^^^^^^^^^^^ private associated function | ::: $WORKSPACE/macros/macroc/src/derive_refusal/type_guard.rs | - | / pub(in crate::derive_refusal) const fn established( - | | cause: RefusalDeriveCapture, - | | token: SpanHandle, - | | ) -> Self { - | |_____________- private associated function defined here + | / pub(in crate::derive_refusal) const fn established( + | | cause: RefusalDeriveCapture, + | | token: SpanHandle, + | | ) -> Self { + | |_________________- private associated function defined here diff --git a/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs b/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs index ff3f630..7b076be 100644 --- a/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs +++ b/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs @@ -21,13 +21,13 @@ mod roles { threadpak::scope_guard_version! { /// Role A's version, positioned under role A's authority. - pub struct RoleAVersion over OneScopeId; + pub struct RoleAVersion over OneScopeId, seated in mod role_a_version; } threadpak::scope_guard_version! { /// Role B's version, positioned under role B's authority — over the very /// same scope. - pub struct RoleBVersion over OneScopeId; + pub struct RoleBVersion over OneScopeId, seated in mod role_b_version; } } diff --git a/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.stderr b/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.stderr index 35866e2..fe4c9aa 100644 --- a/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.stderr +++ b/testpak/tests/compile-fail/a-stamped-representation-cannot-be-laundered.stderr @@ -1,31 +1,24 @@ -error[E0603]: tuple struct constructor `RoleBVersion` is private - --> tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs:36:25 +error[E0423]: cannot initialize a tuple struct which contains private fields + --> tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs:36:18 | -27 | / threadpak::scope_guard_version! { -28 | | /// Role B's version, positioned under role B's authority — over the very -29 | | /// same scope. -30 | | pub struct RoleBVersion over OneScopeId; -31 | | } - | |_____- a constructor is private if any of the fields is private -... -36 | |role_a| roles::RoleBVersion(role_a.0); - | ^^^^^^^^^^^^ private tuple struct constructor +36 | |role_a| roles::RoleBVersion(role_a.0); + | ^^^^^^^^^^^^^^^^^^^ | -note: the tuple struct constructor `RoleBVersion` is defined here +note: constructor is not visible here due to private fields --> tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs:27:5 | 27 | / threadpak::scope_guard_version! { 28 | | /// Role B's version, positioned under role B's authority — over the very 29 | | /// same scope. -30 | | pub struct RoleBVersion over OneScopeId; +30 | | pub struct RoleBVersion over OneScopeId, seated in mod role_b_version; 31 | | } - | |_____^ + | |_____^ private field = note: this error originates in the macro `threadpak::scope_guard_version` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider making the field publicly accessible --> $WORKSPACE/src/02_identity/mod.rs | - | $vis struct $name(pub $crate::identity::AuthorityPosition<$scope>); - | +++ + | $vis struct $name(pub $crate::identity::AuthorityPosition<$scope>); + | +++ error[E0616]: field `0` of struct `RoleAVersion` is private --> tests/compile-fail/a-stamped-representation-cannot-be-laundered.rs:36:45 diff --git a/testpak/tests/compile-fail/a-zero-maximum-family-cannot-mint-a-positive-limit.rs b/testpak/tests/compile-fail/a-zero-maximum-family-cannot-mint-a-positive-limit.rs index 77c54b4..180bae0 100644 --- a/testpak/tests/compile-fail/a-zero-maximum-family-cannot-mint-a-positive-limit.rs +++ b/testpak/tests/compile-fail/a-zero-maximum-family-cannot-mint-a-positive-limit.rs @@ -13,7 +13,8 @@ //! failed too and the empty-only seat would have been refused with it. use threadpak::types::{ - AdmittedLimit, Bounded, ConstLimit, Limit, LimitAdmissionProfile, PositiveLimit, + AdmittedLimit, Bounded, ConstLimit, DeclaredMagnitude, Limit, LimitAdmissionProfile, + PositiveLimit, }; /// The qualification plane's own admitting ceiling, declared here because this @@ -29,7 +30,9 @@ impl LimitAdmissionProfile for QualificationProfile { /// A limit family admitting no item at all. struct NoItemAtAll; -impl Limit for NoItemAtAll {} +impl Limit for NoItemAtAll { + type Authority = DeclaredMagnitude; +} impl ConstLimit for NoItemAtAll { const MAX: usize = 0; diff --git a/testpak/tests/compile-fail/cross-frame-comparison-on-a-production-guard.stderr b/testpak/tests/compile-fail/cross-frame-comparison-on-a-production-guard.stderr index b7c3689..9a4f6c9 100644 --- a/testpak/tests/compile-fail/cross-frame-comparison-on-a-production-guard.stderr +++ b/testpak/tests/compile-fail/cross-frame-comparison-on-a-production-guard.stderr @@ -9,8 +9,8 @@ error[E0369]: binary operation `<` cannot be applied to type `&FrameVersion` note: `FrameVersion` does not implement `PartialOrd` --> $WORKSPACE/src/02_identity/mod.rs | - | $vis struct $name($crate::identity::AuthorityPosition<$scope>); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FrameVersion` is defined in another crate + | $vis struct $name($crate::identity::AuthorityPosition<$scope>); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FrameVersion` is defined in another crate | ::: $WORKSPACE/src/11_navigation/types.rs | @@ -19,7 +19,7 @@ note: `FrameVersion` does not implement `PartialOrd` | | /// frame is a VALUE inside the position rather than a type parameter, so two | | /// frames' versions are ONE type and the compiler never tells them apart: ... | - | | pub struct FrameVersion over ReferenceFrameId; + | | pub struct FrameVersion over ReferenceFrameId, seated in mod frame_version; | | } | |_- in this macro invocation = note: this error originates in the macro `crate::scope_guard_version` (in Nightly builds, run with -Z macro-backtrace for more info) @@ -32,8 +32,8 @@ error[E0599]: the method `cmp` exists for reference `&FrameVersion`, but its tra | ::: $WORKSPACE/src/02_identity/mod.rs | - | $vis struct $name($crate::identity::AuthorityPosition<$scope>); - | --------------------------------------------------------------- doesn't satisfy `FrameVersion: Iterator` or `FrameVersion: Ord` + | $vis struct $name($crate::identity::AuthorityPosition<$scope>); + | --------------------------------------------------------------- doesn't satisfy `FrameVersion: Iterator` or `FrameVersion: Ord` | = note: the following trait bounds were not satisfied: `FrameVersion: Ord` diff --git a/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.rs b/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.rs index 419b5a2..77c18ff 100644 --- a/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.rs +++ b/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.rs @@ -14,12 +14,12 @@ pub struct BetaScopeId; threadpak::scope_guard_version! { /// Alpha's version, positioned by Alpha's authority. - pub struct AlphaVersion over AlphaScopeId; + pub struct AlphaVersion over AlphaScopeId, seated in mod alpha_version; } threadpak::scope_guard_version! { /// Beta's version, positioned by Beta's authority. - pub struct BetaVersion over BetaScopeId; + pub struct BetaVersion over BetaScopeId, seated in mod beta_version; } fn main() { diff --git a/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.stderr b/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.stderr index 5fb05da..b776397 100644 --- a/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.stderr +++ b/testpak/tests/compile-fail/cross-scope-comparison-on-a-stamped-guard.stderr @@ -13,7 +13,7 @@ note: method defined here | 15 | / threadpak::scope_guard_version! { 16 | | /// Alpha's version, positioned by Alpha's authority. -17 | | pub struct AlphaVersion over AlphaScopeId; +17 | | pub struct AlphaVersion over AlphaScopeId, seated in mod alpha_version; 18 | | } | |_^ = note: this error originates in the macro `threadpak::scope_guard_version` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/testpak/tests/compile-fail/singleton-under-a-zero-maximum-family.rs b/testpak/tests/compile-fail/singleton-under-a-zero-maximum-family.rs index 3fc2d69..0f69c39 100644 --- a/testpak/tests/compile-fail/singleton-under-a-zero-maximum-family.rs +++ b/testpak/tests/compile-fail/singleton-under-a-zero-maximum-family.rs @@ -6,12 +6,14 @@ //! that instantiation. Post-monomorphization refusal IS compile-time refusal — //! no artifact carrying this road under a zero-maximum family is ever produced. -use threadpak::types::{ConstLimit, Limit, NonEmptyBounded}; +use threadpak::types::{ConstLimit, DeclaredMagnitude, Limit, NonEmptyBounded}; /// A limit family admitting no item at all. struct NoItemAtAll; -impl Limit for NoItemAtAll {} +impl Limit for NoItemAtAll { + type Authority = DeclaredMagnitude; +} impl ConstLimit for NoItemAtAll { const MAX: usize = 0; diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index c8adeea..18a1679 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -38,14 +38,61 @@ workspace = true # `cfg` is evaluated, and no macro is expanded. The law that reads through it # states those limits itself, in `src/checks/coupling.rs`, where the reading is. # -# `proc-macro2` arrives with the seal law, which judges what band 02's -# `scope_guard_version!` stamp EMITS. A transcriber is not Rust: `$vis struct -# $name(…)` parses as nothing until its metavariables have values. So the law -# gives them values and hands the result to the same `syn` every other reading -# here goes through — which means building a token stream, which means naming -# the type one is. The alternative was text surgery on a macro body, which is -# the line-scanner shape this crate replaced once already and would be reaching -# for again one level down. +# `proc-macro2` NO LONGER STANDS FOR THE LAW THAT ADMITTED IT, and it stays +# anyway, for a second reading that earned it independently. It arrived for a law +# that judged what band 02's `scope_guard_version!` stamp EMITS, by giving a +# transcriber's metavariables values and parsing the result. That law is deleted: +# the stamp now writes its guard into a module of its own, so the set of roads +# out of a stamped guard is the expansion itself and `rustc` refuses everything +# else — the drain running downward, and the mechanism would have gone with it. +# What holds the edge open is `src/checks/placement.rs`: a crate-root reference +# is a PATH IN A TOKEN STREAM, and `proc-macro2` owns Rust's token grammar, so a +# longer identifier ending in `crate`, the word inside a comment, and the word +# inside a string literal are each what they are rather than a substring match. +# The line reader that stood there before matched text and was wrong about all +# three. So the edge is re-earned at a different reading rather than inherited +# from the deleted one, and this paragraph is where that hand-over is written +# down; the day nothing lexes Rust here, it goes. +# +# THE SAME ADMISSION, ONE CLASS WIDER, and what earned it. +# +# `syn` was admitted because a line scanner cannot answer a question about Rust +# ITEMS. That argument was never about Rust. It is about a reader weaker than the +# one that already owns the language answering questions in it, and this crate +# was doing exactly that in three more languages at once: Cargo's syntax, read by +# cutting a line at its first `=`; Markdown, read by counting fences; and the +# resolved dependency graph, read by matching manifest text. Eleven TOML +# spellings escaped the line reader over one campaign, each repair revealing the +# next, and the printed denominators stayed whole the entire time — which is the +# single failure a derived denominator exists to prevent, arriving through the +# reader that feeds it. +# +# So the same rule now holds for every language this crate reads: the decoder +# that OWNS a language answers questions in it, and no reader here re-derives +# what one of them already establishes. +# +# - `toml` answers Cargo's syntax. What a manifest DECLARES. +# - `serde_json` + `serde` answer `cargo metadata --format-version 1`. What +# Cargo RESOLVES — which is a different question from what a manifest +# declares, and cargo is its only honest oracle. +# - `pulldown-cmark` answers Markdown structure. WHICH fenced block a reading +# is about, so a data block is selected by what the document declares rather +# than by its position among the fences. +# +# Which version, and which features, is the workspace's one decision, written in +# the root manifest's `[workspace.dependencies]` table and inherited here. +# +# What the admission does NOT buy, in each case: nothing is compiled, no feature +# is resolved by this crate itself, no Markdown is rendered, and no TOML is +# written. The schema of a fenced DATA block — this repository's own obligation +# ledger — is owned here rather than by a decoder, and `src/repository/markdown.rs` +# states the measurement behind that: the ledger blocks this repository commits +# are yaml-SHAPED and are not YAML documents, and every YAML decoder available +# breaks `multiple-versions = "deny"`. [dependencies] proc-macro2 = { workspace = true } +pulldown-cmark = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } syn = { workspace = true } +toml = { workspace = true } diff --git a/xtask/src/checks/alarms.rs b/xtask/src/checks/alarms.rs new file mode 100644 index 0000000..25b7a8a --- /dev/null +++ b/xtask/src/checks/alarms.rs @@ -0,0 +1,519 @@ +//! The two alarms standing beside the entry bar are counted where a machine +//! reads. +//! +//! `cargo xtask qualify` is the entry bar and the only spelling of it. Two +//! surfaces stand BESIDE it rather than inside it, each for a reason written at +//! its own home: the second harness, which runs the workspace's tests one +//! process per test where the bar's `tests` stage runs them as threads in one, +//! and the mutation alarm, which damages one decision at a time and asks whether +//! any test notices. Neither is a gate, neither becomes one, and both are +//! committed as files a hosted job reads: +//! +//! - `.config/nextest.toml` and `.github/workflows/harness.yml`; +//! - `.cargo/mutants.toml` and `.github/workflows/mutation.yml`. +//! +//! # Admission requirement six, unsatisfied, and this is it +//! +//! Until this law was registered those four files stood outside every +//! denominator this repository publishes. The red ledgers in +//! `crate::checks::obligations` count testpak's tests and its compile-fail +//! fixtures, and a configuration beside a hosted workflow is neither; the +//! mutation run derives a denominator of its own, but a run publishes it rather +//! than a law joining it, so nothing refused when the file that scoped the run +//! disappeared. DELETING ANY ONE OF THE FOUR MOVED NO NUMBER AND FAILED NO +//! STAGE. +//! +//! Both files said so about themselves, in their own prose, and writing a gap +//! down does not close it — prose keeps reading as true after the thing it +//! describes is gone. They also named the exact shape that would close it: a +//! repository law of the shape +//! `crate::checks::supply_chain::check_dependency_gate_artifacts`, reading these +//! two configurations and the two workflows that call them, refusing when one is +//! deleted, when one is emptied, or when the `reversal` profile stops departing +//! from `default`. This is that law, in that shape, and it claims what that shape +//! claims and no more. +//! +//! # What is claimed here +//! +//! Six facts about FILES: the four artifacts are committed, the two +//! configurations state something, and the harness configuration's planted +//! `reversal` profile is visibly distinct from the `default` profile it stands +//! against. The alarms' artifacts are PRESENT and VISIBLY DISTINCT — which is +//! what the roster entry is called, in those words. +//! +//! # The reading is the decoder's, not a scan's +//! +//! A configuration that states nothing is read as a DECODED DOCUMENT declaring +//! no key, and a profile that stopped departing is read as two decoded tables +//! that are equal. Both questions are questions about TOML, so the decoder that +//! owns TOML answers them: a file emptied to whitespace, a file emptied to +//! comments, and a reversal resynchronized with the default through a different +//! spelling of the same values are one answer here rather than three cases a +//! text scan would have to be taught one at a time. +//! +//! # The ceiling, named here because the name would otherwise imply past it +//! +//! **It does not claim any hosted step still RUNS them.** Whether +//! `.github/workflows/harness.yml` still invokes the reversal profile, and +//! whether `.github/workflows/mutation.yml` still counts what it examined, is +//! unestablished here — not false, not true, unread — and this law FAILS OPEN on +//! it: a workflow file somebody emptied of every step still satisfies every +//! reading below. That ceiling is executed as a planted test rather than only +//! written in this paragraph, because a sentence is what this whole law exists to +//! stop trusting. +//! +//! The opening condition is the same one the dependency gate names and does not +//! reach: a hosted run publishing the roster of what it EXECUTED. A step that ran +//! appears in such a roster and a step somebody deleted does not, which answers +//! the question by reading an output rather than by guessing at a source. Until +//! then a deleted STEP is caught by review and by nothing else, and a deleted +//! FILE is caught here. +//! +//! **It does not claim the departure is the PLANTED one.** That the `reversal` +//! profile differs from `default` is established here; that it differs by a +//! one-second kill threshold against a test written to sleep past it is +//! established by the harness job's own requirement of exit 100, a timed-out +//! test, AND that test's name in the transcript, and that requirement stands +//! exactly as long as that step does. +//! +//! **It does not claim either tool is installed anywhere.** Both are separately +//! installed binaries, which is why they stand beside the bar rather than in it, +//! and nothing here starts either one. + +use crate::repository::cargo::table_at; +use crate::repository::snapshot::RepositorySnapshot; +use crate::repository::types::Read; + +/// The second harness's configuration, in path segments so the join spells no +/// separator of its own. +const HARNESS_CONFIGURATION: [&str; 2] = [".config", "nextest.toml"]; + +/// The hosted seat the second harness runs in. +const HARNESS_WORKFLOW: [&str; 3] = [".github", "workflows", "harness.yml"]; + +/// The mutation alarm's configuration, which scopes what a run examines. +const MUTATION_CONFIGURATION: [&str; 2] = [".cargo", "mutants.toml"]; + +/// The hosted seat the mutation alarm runs in. +const MUTATION_WORKFLOW: [&str; 3] = [".github", "workflows", "mutation.yml"]; + +/// The table the harness configuration's profiles hang beneath. +const PROFILE_TABLE: &str = "profile"; + +/// The profile a lawful run reads. +const LAWFUL_PROFILE: &str = "default"; + +/// The profile that is deliberately wrong, kept on purpose. +const REVERSAL_PROFILE: &str = "reversal"; + +/// The two standing alarms' committed artifacts are present and visibly +/// distinct. +/// +/// # Errors +/// +/// Returns the offences together rather than one per run, because they are +/// independent of one another and a repairer who deleted two parts should learn +/// about both at once. A configuration that is present and cannot be decoded is +/// a failure of the run itself rather than an offence: whether it states +/// anything is then unknown rather than false. +pub(crate) fn check_alarm_artifacts(snapshot: &RepositorySnapshot) -> Result<(), String> { + let mut offences = Vec::new(); + committed( + snapshot, + &spelled(&HARNESS_WORKFLOW), + &mut offences, + "the second harness's hosted seat is not committed: no file in this repository names a run \ + that would read `.config/nextest.toml`, so the configuration below it is a decision \ + nobody executes", + ); + committed( + snapshot, + &spelled(&MUTATION_WORKFLOW), + &mut offences, + "the mutation alarm's hosted seat is not committed: no file in this repository names a run \ + that would read `.cargo/mutants.toml`, so the scope it declares selects sources for \ + nobody", + ); + committed( + snapshot, + &spelled(&HARNESS_CONFIGURATION), + &mut offences, + "the second harness's configuration is not committed: nothing states which nextest a run \ + requires, which profile it reads, or that a planted reversal exists at all, so the second \ + reading of the test population has stopped being a second reading of anything", + ); + committed( + snapshot, + &spelled(&MUTATION_CONFIGURATION), + &mut offences, + "the mutation alarm's configuration is not committed: nothing scopes what a run examines, \ + and a run with no scope takes the root package alone, finds no mutant, and exits zero — \ + which a job reads as success", + ); + if let Some(harness) = decoded(snapshot, &spelled(&HARNESS_CONFIGURATION))? { + judge_harness(harness, &mut offences); + } + if let Some(mutation) = decoded(snapshot, &spelled(&MUTATION_CONFIGURATION))? + && mutation.is_empty() + { + offences.push(String::from( + "`.cargo/mutants.toml` states no key at all: a configuration that scopes nothing is a \ + run that examines the root package alone and reports zero mutants as success", + )); + } + if offences.is_empty() { + Ok(()) + } else { + Err(offences.join("; ")) + } +} + +/// Judges the harness configuration: it states something, and its planted +/// reversal profile departs from the profile it stands against. +fn judge_harness(document: &toml::Table, offences: &mut Vec) { + if document.is_empty() { + offences.push(String::from( + "`.config/nextest.toml` states no key at all: a configuration carrying no profile \ + cannot be departed from, so the planted reversal has stopped being a reversal", + )); + return; + } + let lawful = table_at(document, &[PROFILE_TABLE, LAWFUL_PROFILE]); + let planted = table_at(document, &[PROFILE_TABLE, REVERSAL_PROFILE]); + let (Some(lawful), Some(planted)) = (lawful.known(), planted.known()) else { + offences.push(format!( + "`.config/nextest.toml` does not state both `[{PROFILE_TABLE}.{LAWFUL_PROFILE}]` and \ + `[{PROFILE_TABLE}.{REVERSAL_PROFILE}]`: the planted reversal is the one committed \ + artifact by which it can be established that this file is READ at all, and a profile \ + that is not there is refused by nextest rather than watched by anybody" + )); + return; + }; + if planted.is_empty() { + offences.push(format!( + "`[{PROFILE_TABLE}.{REVERSAL_PROFILE}]` states nothing: a profile that overrides no \ + setting inherits every one of them, so a run under it is the lawful run wearing \ + another name and it passes exactly where the reversal was supposed to refuse" + )); + } + if **lawful == **planted { + offences.push(format!( + "`[{PROFILE_TABLE}.{REVERSAL_PROFILE}]` states what \ + `[{PROFILE_TABLE}.{LAWFUL_PROFILE}]` states: the planted reversal has been \ + synchronized with the profile it stands against, so it is no longer distinct from it \ + and carries nothing wrong left to refuse" + )); + } +} + +/// One artifact's presence, with the words its absence is reported in. +fn committed( + snapshot: &RepositorySnapshot, + relative: &str, + offences: &mut Vec, + said: &str, +) { + if snapshot.files().get(relative).is_none() { + offences.push(format!("`{relative}`: {said}")); + } +} + +/// One configuration as the decoder that owns TOML resolved it, or nothing +/// where the tree carries no such file. +/// +/// An absence is not reported here — the presence reading above already reported +/// it, and one disappearance stated twice reads as two. A file that IS there and +/// does not decode refuses the whole law, because whether it states anything is +/// then unknown rather than false. +fn decoded<'snapshot>( + snapshot: &'snapshot RepositorySnapshot, + relative: &str, +) -> Result, String> { + match snapshot.cargo().document(relative) { + Read::Known(document) => Ok(Some(document)), + Read::DeclaredAbsent(_) => Ok(None), + Read::Unreadable(failure) => Err(format!("{relative} could not be decoded: {failure}")), + } +} + +/// One artifact as this repository spells paths — relative, forward slashes — +/// so a run on any machine names the same file. +fn spelled(segments: &[&str]) -> String { + segments.join("/") +} + +/// Planted reversals for a law whose subject is the tree, and the second +/// harness's own planted subject. +/// +/// The alarms are a set of files, so their reversals are planted against a +/// scratch root outside the repository: the law that counts the artifacts is +/// never proven by deleting them. Two tests read the ceiling and the real tree, +/// and each states what it found. +/// +/// The last test is a different kind of thing and sits here because this is the +/// module the alarms are counted in: it is the SUBJECT of the second harness's +/// planted reversal rather than a control for the law above, and it runs under +/// exactly one profile in one hosted step. +#[cfg(test)] +mod tests { + use super::{ + HARNESS_CONFIGURATION, HARNESS_WORKFLOW, MUTATION_CONFIGURATION, MUTATION_WORKFLOW, + check_alarm_artifacts, spelled, + }; + use crate::checks::scratch::Scratch; + use crate::repository::snapshot::repository_snapshot; + + /// A fixture harness configuration: a lawful profile and a planted reversal + /// that departs from it. + const HARNESS_FIXTURE: &str = "nextest-version = \"0.9.132\"\n\n\ + [profile.default]\n\ + fail-fast = false\n\ + retries = 0\n\n\ + [profile.reversal]\n\ + default-filter = 'test(the_nextest_timeout_reversal)'\n"; + + /// A fixture mutation configuration, standing for the scope a run examines. + const MUTATION_FIXTURE: &str = "examine_globs = [\"xtask/**/*.rs\"]\ncap_lints = true\n"; + + /// A fixture workflow, standing for either alarm's hosted seat. + const WORKFLOW_FIXTURE: &str = "name: harness\n"; + + /// One scratch root carrying both alarms whole. + fn planted(name: &str) -> Scratch { + let scratch = Scratch::named(name); + scratch.write(&spelled(&HARNESS_WORKFLOW), WORKFLOW_FIXTURE); + scratch.write(&spelled(&MUTATION_WORKFLOW), WORKFLOW_FIXTURE); + scratch.write(&spelled(&HARNESS_CONFIGURATION), HARNESS_FIXTURE); + scratch.write(&spelled(&MUTATION_CONFIGURATION), MUTATION_FIXTURE); + scratch + } + + /// The positive control: both alarms carrying all four artifacts, with a + /// reversal profile that states something and departs from the profile it + /// stands against, is lawful. A law that refused everything would satisfy + /// every reversal below and be worthless. + #[test] + fn present_and_distinct_artifacts_are_lawful() -> Result<(), String> { + let scratch = planted("alarms-whole"); + let found = check_alarm_artifacts(&scratch.read()?); + assert!(found.is_ok(), "{found:?}"); + Ok(()) + } + + /// Planted reversal: the second harness's configuration deleted. This is one + /// half of the failure the law exists for — before it, deleting this file + /// moved no number and failed no stage. + #[test] + fn a_deleted_harness_configuration_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-harness-configuration-deleted"); + scratch.remove(&spelled(&HARNESS_CONFIGURATION)); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains(".config/nextest.toml") + && reason.contains("is not committed")), + "a deleted harness configuration passed the law that counts it" + ); + Ok(()) + } + + /// Planted reversal: the mutation alarm's configuration deleted, which is + /// the other half. A run with no scope examines the root package alone, + /// finds nothing, and exits zero. + #[test] + fn a_deleted_mutation_configuration_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-mutation-configuration-deleted"); + scratch.remove(&spelled(&MUTATION_CONFIGURATION)); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains(".cargo/mutants.toml") + && reason.contains("is not committed")), + "a deleted mutation configuration passed" + ); + Ok(()) + } + + /// Planted reversal: the harness workflow deleted, leaving a configuration + /// no committed file names a run for. + #[test] + fn a_deleted_harness_workflow_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-harness-workflow-deleted"); + scratch.remove(&spelled(&HARNESS_WORKFLOW)); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("harness.yml") + && reason.contains("hosted seat is not committed")), + "a deleted harness workflow passed" + ); + Ok(()) + } + + /// Planted reversal: the mutation workflow deleted. + #[test] + fn a_deleted_mutation_workflow_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-mutation-workflow-deleted"); + scratch.remove(&spelled(&MUTATION_WORKFLOW)); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("mutation.yml") + && reason.contains("hosted seat is not committed")), + "a deleted mutation workflow passed" + ); + Ok(()) + } + + /// Planted reversal: a configuration emptied rather than deleted — the same + /// disappearance written one edit shallower, and the one a listing of the + /// tree reports as present. Emptied to COMMENTS rather than to whitespace, + /// because that is the spelling a text scan would read as content and the + /// decoder reads as the empty document it is. + #[test] + fn an_emptied_mutation_configuration_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-mutation-configuration-emptied"); + scratch.write( + &spelled(&MUTATION_CONFIGURATION), + "# every decision that was here is gone\n", + ); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("states no key at all")), + "an emptied mutation configuration passed" + ); + Ok(()) + } + + /// Planted reversal: the reversal profile deleted out of a configuration + /// that is otherwise whole. Every artifact is present and the one committed + /// thing by which this file can be shown to be READ is gone. + #[test] + fn a_harness_configuration_with_no_reversal_profile_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-reversal-profile-deleted"); + scratch.write( + &spelled(&HARNESS_CONFIGURATION), + "[profile.default]\nfail-fast = false\n", + ); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("does not state both")), + "a configuration carrying no reversal profile passed" + ); + Ok(()) + } + + /// Planted reversal: the reversal profile synchronized with the profile it + /// stands against until it is no longer wrong. + /// + /// The most expensive spelling of the defect, because every artifact is + /// present and a listing of the tree says both alarms are whole. A reversal + /// that agrees with the profile it departs from is not distinct from it, and + /// the one thing the profile was committed to be — a configuration that is + /// wrong on purpose — has quietly stopped being true of it. + #[test] + fn a_reversal_profile_that_stopped_departing_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-reversal-profile-synchronized"); + scratch.write( + &spelled(&HARNESS_CONFIGURATION), + "[profile.default]\nfail-fast = false\n\n[profile.reversal]\nfail-fast = false\n", + ); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("has been synchronized")), + "a reversal profile that is a copy of the default passed" + ); + Ok(()) + } + + /// Planted reversal: an emptied reversal profile, which is the same + /// disappearance one edit shallower again. A profile that overrides nothing + /// inherits everything, so a run under it is the lawful run under another + /// name. + #[test] + fn an_emptied_reversal_profile_is_a_violation() -> Result<(), String> { + let scratch = planted("alarms-reversal-profile-emptied"); + scratch.write( + &spelled(&HARNESS_CONFIGURATION), + "[profile.default]\nfail-fast = false\n\n[profile.reversal]\n", + ); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("states nothing")), + "an emptied reversal profile passed" + ); + Ok(()) + } + + /// The ceiling, executed rather than only written down: workflows that + /// invoke nothing at all still pass this law. + /// + /// It proves nothing about either alarm, and it is not a positive control. + /// What it holds in place is the NAME. The fixture workflows here carry no + /// job and no step, exactly as workflows somebody edited every step out of + /// carry none, and this law is content with both — which is the fail open the + /// module documentation states. A later reader who widens this law to read + /// what a hosted run executed has to delete this test to do it, and that + /// deletion is the line of the diff where the claim is allowed to grow back. + #[test] + fn workflows_that_invoke_nothing_still_pass() -> Result<(), String> { + let scratch = planted("alarms-workflows-invoke-nothing"); + let empty = "name: harness\non:\n schedule:\n - cron: '0 0 * * 0'\njobs: {}\n"; + scratch.write(&spelled(&HARNESS_WORKFLOW), empty); + scratch.write(&spelled(&MUTATION_WORKFLOW), empty); + let found = check_alarm_artifacts(&scratch.read()?); + assert!( + found.is_ok(), + "this law reads files and not steps; a verdict here would mean it had started reading \ + steps without its name saying so: {found:?}" + ); + Ok(()) + } + + /// The real repository holds: both alarms' four artifacts are committed, the + /// two configurations state something, and the planted reversal profile + /// departs from the profile it stands against. + #[test] + fn the_real_alarm_artifacts_are_present_and_distinct() -> Result<(), String> { + let found = check_alarm_artifacts(repository_snapshot()?); + assert!(found.is_ok(), "{found:?}"); + Ok(()) + } + + /// Seconds the second harness's planted subject sleeps: thirty times the + /// one-second kill threshold `.config/nextest.toml`'s `reversal` profile + /// sets against it. Nothing about the number is compiled, resolved, or + /// cached, so it is the same margin on every host. + const SLEEP_SECONDS: u64 = 30; + + /// The SUBJECT of the second harness's planted reversal: a test whose entire + /// body is an instruction to sleep past the threshold that profile kills it + /// at. + /// + /// It runs under that one profile and nowhere else. `#[ignore]` is what + /// keeps it out of every other population — `cargo test`, the entry bar's + /// `tests` stage, the second harness's own positive run, and every mutation + /// rebuild report it skipped and pay nothing for it — and the reversal step + /// lifts the skip with `--run-ignored only` while the profile's + /// `default-filter` names this function. So the attribute here is not a test + /// nobody runs: it is a test exactly one run selects, and that run is the one + /// that requires it to be killed. + /// + /// WHY A DEDICATED TEST RATHER THAN THE SLOWEST REAL ONE. The plant this + /// replaces set the same one-second threshold against `compile_refusals`, on + /// the argument that a seventeen-second test cannot finish inside a second. + /// Those seventeen seconds are rustc's, and the positive run in the same job + /// pays for every compilation product they buy: on the hosted runner the + /// reversal run then finished inside the second, exited 0, and the step that + /// requires a refusal went red. A sleep has no cache. This body takes the + /// same time cold, warm, and busy, so the refusal it produces is a fact about + /// this configuration being READ rather than about how much work the host had + /// already done. + /// + /// It asserts nothing, on purpose. Reaching the end of this body is the + /// outcome the harness job reads as a failure — the run exits 0 and the step + /// goes red — so an assertion here would be a second thing to keep true and + /// the first thing to drift. + #[test] + #[ignore = "the subject of the second harness's planted reversal: selected by \ + `.config/nextest.toml`'s `reversal` profile, which kills it after one second"] + fn the_nextest_timeout_reversal() { + std::thread::sleep(std::time::Duration::from_secs(SLEEP_SECONDS)); + } +} diff --git a/xtask/src/checks/coupling.rs b/xtask/src/checks/coupling.rs index da7dc68..8665c91 100644 --- a/xtask/src/checks/coupling.rs +++ b/xtask/src/checks/coupling.rs @@ -69,10 +69,8 @@ //! or a body assembled by one is outside this law — and this law does not pretend //! otherwise. -use std::fs; -use std::path::Path; - -use crate::repository::walk::{TOOLING_DIRECTORY, relative_slash_path, visit_files}; +use crate::repository::snapshot::{MACHINE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY}; +use crate::repository::types::CanonicalPath; /// The proof surfaces, excluded from the population by name. /// @@ -111,8 +109,10 @@ const LOOSE_CARRY: &str = "NonEmptyBounded"; /// Returns the offences one line at a time, and returns a read failure as /// itself: a gate that cannot read its subject says so rather than reporting an /// empty population. -pub(crate) fn check_collection_bodies_are_coupled(root: &Path) -> Result<(), String> { - let sources = coupling_sources(root)?; +pub(crate) fn check_collection_bodies_are_coupled( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { + let sources = coupling_sources(snapshot)?; let verdict = coupled_body_verdict(&sources); // The denominator is DERIVED and printed on every run, because a population @@ -175,24 +175,22 @@ struct Reading { families: Vec, /// Every public body, in every module the pass entered. bodies: Vec, - /// Sources that are not parseable Rust, one offence each. Never a skip: a - /// file this reader could not read is a hole in the population, and a hole - /// reported as nothing is the defect this whole leg is about. - unparsable: Vec, } -/// Reads the declarations and the bodies out of source text and judges each +/// Reads the declarations and the bodies out of parsed trees and judges each /// family. /// -/// Pure over its inputs — `(repository-relative path, source text)` pairs — so -/// the reversals below are planted in memory and the law that guards the tree is -/// never proven by editing one. -fn coupled_body_verdict(sources: &[(String, String)]) -> CouplingVerdict { +/// Pure over its inputs — `(canonical path, parsed tree)` pairs handed over by +/// the snapshot — so the reversals below are planted in memory and the law that +/// guards the tree is never proven by editing one. A source that did not parse +/// never reaches here: the snapshot carries it as unread, and the caller refuses +/// the whole reading rather than deriving a population one file short. +fn coupled_body_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> CouplingVerdict { let reading = read_sources(sources); let mut verdict = CouplingVerdict { declared: 0, coupled: 0, - offenders: reading.unparsable, + offenders: Vec::new(), }; for declared in &reading.families { verdict.declared = verdict.declared.saturating_add(1); @@ -258,22 +256,15 @@ fn declares(seats: &[Option], named: &str) -> bool { .any(|seat| seat.as_deref().is_some_and(|head| head == named)) } -/// Parses every source and reads the declarations, the bodies, and the failures -/// out of the trees. -fn read_sources(sources: &[(String, String)]) -> Reading { +/// Reads the declarations and the bodies out of the parsed trees. +fn read_sources(sources: &[(&CanonicalPath, &syn::File)]) -> Reading { let mut reading = Reading { families: Vec::new(), bodies: Vec::new(), - unparsable: Vec::new(), }; - for (path, text) in sources { - match syn::parse_file(text) { - Ok(file) => read_module(path, &declaring_home(path), &file.items, &mut reading), - Err(error) => reading.unparsable.push(format!( - "{path}: this file is not parseable Rust, so the population derived from it is \ - unknown rather than empty: {error}" - )), - } + for (path, file) in sources { + let spelled = path.as_str(); + read_module(spelled, &declaring_home(spelled), &file.items, &mut reading); } reading } @@ -297,38 +288,111 @@ fn declaring_home(path: &str) -> String { /// /// An inline `mod` is its own resolution scope, keyed by the file that writes it /// so two files' identically named inline modules never resolve into each other. -#[expect( - clippy::wildcard_enum_match_arm, - reason = "`syn::Item` is non_exhaustive, so no crate outside syn can enumerate its variants; a wildcard is the only arm that closes this match, and every item it catches is one this reading has no question about" -)] +/// Written as an `if let` chain rather than a match because `syn::Item` is +/// `non_exhaustive`: the items this reading has a question about are named, and +/// every other item is passed over without a wildcard arm standing in for a set +/// no crate outside `syn` can enumerate. The expectation that used to sit here — +/// naming the lint and the reason — was a real refusal of a real hatch, and it +/// is gone because the shape that needed it is gone. fn read_module(path: &str, home: &str, items: &[syn::Item], reading: &mut Reading) { for item in items { - match item { - syn::Item::Impl(declared) => { - if let Some(family) = collection_family(declared) { - reading.families.push(DeclaredFamily { - path: path.to_string(), - home: home.to_string(), - family, - }); - } + if let syn::Item::Impl(declared) = item { + if let Some(family) = collection_family(declared) { + reading.families.push(DeclaredFamily { + path: path.to_string(), + home: home.to_string(), + family, + }); } - syn::Item::Struct(declared) => { - if matches!(declared.vis, syn::Visibility::Public(_)) { - reading.bodies.push(DeclaredBody { - home: home.to_string(), - name: declared.ident.to_string(), - seats: body_seats(&declared.fields), - }); - } + } else if let syn::Item::Struct(declared) = item { + if matches!(declared.vis, syn::Visibility::Public(_)) { + reading.bodies.push(DeclaredBody { + home: home.to_string(), + name: declared.ident.to_string(), + seats: body_seats(&declared.fields), + }); } - syn::Item::Mod(module) => { - if let Some((_, inner)) = &module.content { - let inside = format!("{path}::{}", module.ident); - read_module(path, &inside, inner, reading); - } + } else if let syn::Item::Mod(module) = item { + read_inline_module(path, home, module, items, reading); + } + } +} + +/// Reads one inline module, and then the bodies the enclosing module re-exports +/// out of it. +/// +/// A name the enclosing module RE-EXPORTS is a name in the enclosing scope, so +/// the body behind it is read there too. Without this leg, a record moved into a +/// child module — which is how a private seat is walled off from the file around +/// it — would stop resolving against the family implementation written beside +/// it, and this law would refuse the very shape that narrows the wall. +fn read_inline_module( + path: &str, + home: &str, + module: &syn::ItemMod, + siblings: &[syn::Item], + reading: &mut Reading, +) { + let Some((_, inner)) = &module.content else { + return; + }; + let inside = format!("{path}::{}", module.ident); + read_module(path, &inside, inner, reading); + for name in reexported_from(&module.ident, siblings) { + read_reexported_body(home, &name, inner, reading); + } +} + +/// Every name one module re-exports out of the named child module. +/// +/// A glob contributes nothing: what stands behind one is a set this reader would +/// have to resolve, and a body left in the inner scope produces a refusal +/// somebody can argue with rather than a silence nobody can see. +fn reexported_from(child: &syn::Ident, items: &[syn::Item]) -> Vec { + let mut names = Vec::new(); + for item in items { + if let syn::Item::Use(declared) = item + && let syn::UseTree::Path(rooted) = &declared.tree + && rooted.ident == *child + { + reexported_names(&rooted.tree, &mut names); + } + } + names +} + +/// Every name one `use` tree brings in, under the spelling the enclosing module +/// then knows it by. +fn reexported_names(tree: &syn::UseTree, into: &mut Vec) { + match *tree { + syn::UseTree::Name(ref named) => into.push(named.ident.to_string()), + syn::UseTree::Rename(ref renamed) => into.push(renamed.rename.to_string()), + syn::UseTree::Group(ref group) => { + for inner in &group.items { + reexported_names(inner, into); } - _ => {} + } + syn::UseTree::Path(ref deeper) => reexported_names(&deeper.tree, into), + syn::UseTree::Glob(_) => {} + } +} + +/// Reads one re-exported record into the scope that re-exports it. +/// +/// Only a top-level declaration of the child module answers: a name re-exported +/// out of a module that does not itself declare it stands for something this +/// reader would have to follow further, and it declines to guess. +fn read_reexported_body(home: &str, name: &str, items: &[syn::Item], reading: &mut Reading) { + for item in items { + if let syn::Item::Struct(declared) = item + && matches!(declared.vis, syn::Visibility::Public(_)) + && declared.ident == name + { + reading.bodies.push(DeclaredBody { + home: home.to_string(), + name: name.to_string(), + seats: body_seats(&declared.fields), + }); } } } @@ -402,29 +466,22 @@ fn last_segment(path: &syn::Path) -> Option { path.segments.last().map(|last| last.ident.to_string()) } -/// Every source file the population is derived from: the machine's own sources -/// and the services', minus the two proof surfaces. -fn coupling_sources(root: &Path) -> Result, String> { - let mut sources = Vec::new(); - for directory in ["src", TOOLING_DIRECTORY] { - let base = root.join(directory); - if !base.is_dir() { - continue; - } - visit_files(&base, &mut |path| { - if path.extension().is_none_or(|extension| extension != "rs") { - return Ok(()); - } - let relative = relative_slash_path(root, path); - if PROOF_SURFACES.contains(&relative.as_str()) { - return Ok(()); - } - let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - sources.push((relative, text)); - Ok(()) - })?; - } - Ok(sources) +/// Every parsed source the population is derived from: the machine's own +/// sources and the services', minus the two proof surfaces. +/// +/// Taken from the one reading. A source the snapshot could not parse refuses +/// the whole law rather than leaving the population one file short — a +/// denominator that shrank in silence is the single failure a derived +/// denominator exists to prevent. +fn coupling_sources( + snapshot: &RepositorySnapshot, +) -> Result, String> { + Ok(snapshot + .rust() + .parsed_under(&[MACHINE_DIRECTORY, TOOLING_DIRECTORY])? + .into_iter() + .filter(|(path, _)| !PROOF_SURFACES.contains(&path.as_str())) + .collect()) } /// Planted reversals for the join, and the real repository judged by it. @@ -435,8 +492,9 @@ fn coupling_sources(root: &Path) -> Result, String> { /// states what it found rather than what it hoped for. #[cfg(test)] mod tests { - use super::{coupled_body_verdict, coupling_sources}; - use crate::repository::walk::repo_root; + use super::{CouplingVerdict, coupled_body_verdict as verdict_of_trees, coupling_sources}; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; /// One synthetic source file. fn source(text: &str) -> Vec<(String, String)> { @@ -448,6 +506,32 @@ mod tests { (path.to_string(), text.to_string()) } + /// The verdict over fixture source TEXT. + /// + /// The law itself is handed trees the snapshot already parsed, so a source + /// it could not read never reaches it. A fixture is text, so this adapter + /// parses one and reports a fixture that does not parse exactly as the + /// reading reports a source it could not read — which keeps the reversal + /// below about a hole in the population rather than about who parses. + fn coupled_body_verdict(sources: &[(String, String)]) -> CouplingVerdict { + let mut parsed = Vec::new(); + let mut unparsable = Vec::new(); + for (path, text) in sources { + match syn::parse_file(text) { + Ok(file) => parsed.push((CanonicalPath::spelled(path), file)), + Err(error) => unparsable.push(format!( + "{path}: this file is not parseable Rust, so the population derived from it \ + is unknown rather than empty: {error}" + )), + } + } + let trees: Vec<(&CanonicalPath, &syn::File)> = + parsed.iter().map(|(path, file)| (path, file)).collect(); + let mut verdict = verdict_of_trees(&trees); + verdict.offenders.splice(0..0, unparsable); + verdict + } + /// The positive control: a family whose body is the one coupled seat. A /// check that flagged everything would satisfy every reversal below and be /// worthless. @@ -468,6 +552,56 @@ mod tests { assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); } + /// A body declared inside a child module and RE-EXPORTED by the module the + /// implementation is written in resolves in the enclosing scope, because a + /// re-export is what puts the name there. + /// + /// This is the shape a sealed record takes: the seat module walls the + /// private field off from the file around it, and the family implementation + /// stays outside because it does not need the seat. A reader that stopped at + /// the module boundary would report the body missing and refuse the exact + /// move that narrows the wall. + #[test] + fn a_re_exported_body_resolves_in_the_module_that_publishes_it() { + let verdict = coupled_body_verdict(&source( + "pub use seat::DemoRefusal;\n\ + \n\ + mod seat {\n\ + \x20 pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + \x20 }\n\ + }\n\ + \n\ + impl RefusalFamily for DemoRefusal {\n\ + \x20 const SHAPE: FamilyShape = FamilyShape::IssueCollection;\n\ + }\n", + )); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.coupled, 1, "{:?}", verdict.offenders); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// A body a child module declares and nobody re-exports stays in the child's + /// own scope, so a family implementation outside it does not resolve against + /// it. The lift follows a written re-export and never a name collision. + #[test] + fn a_body_nobody_re_exports_stays_inside_its_own_module() { + let verdict = coupled_body_verdict(&source( + "mod seat {\n\ + \x20 pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + \x20 }\n\ + }\n\ + \n\ + impl RefusalFamily for DemoRefusal {\n\ + \x20 const SHAPE: FamilyShape = FamilyShape::IssueCollection;\n\ + }\n", + )); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.coupled, 0); + assert!(!verdict.offenders.is_empty()); + } + /// Planted reversal: the two-seat body this law exists to end. Both halves /// read as honest and the pair is what nothing else catches. #[test] @@ -874,27 +1008,19 @@ mod tests { /// never opened, and the real error — the one naming the path and the /// operating system's reason — would be gone. #[test] - fn the_real_tree_couples_every_collection_body() { - let read = repo_root() - .map_err(|error| format!("the repository root could not be found: {error}")) - .and_then(|root| coupling_sources(&root)) - .map(|sources| coupled_body_verdict(&sources)); - assert!( - read.is_ok(), - "the coupling gate could not read its subject: {read:?}" - ); + fn the_real_tree_couples_every_collection_body() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = coupling_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + assert!(verdict.offenders.is_empty(), "{verdict:?}"); assert!( - read.as_ref() - .is_ok_and(|verdict| verdict.offenders.is_empty()), - "{read:?}" + verdict.declared > 0, + "no collection-shaped family found in the real tree: {verdict:?}" ); - assert!( - read.as_ref().is_ok_and(|verdict| verdict.declared > 0), - "no collection-shaped family found in the real tree: {read:?}" - ); - assert!( - read.is_ok_and(|verdict| verdict.coupled == verdict.declared), + assert_eq!( + verdict.coupled, verdict.declared, "the real tree declares a collection-shaped family whose body is not the coupled seat" ); + Ok(()) } } diff --git a/xtask/src/checks/dependency.rs b/xtask/src/checks/dependency.rs index dc1a277..89e10d0 100644 --- a/xtask/src/checks/dependency.rs +++ b/xtask/src/checks/dependency.rs @@ -2,23 +2,50 @@ //! //! The edges run one way and inward. The machine depends on nothing that //! projects its contracts and nothing that judges it; a compiler service depends -//! on no frontend of its own. Both halves are read off declared manifests rather -//! than off a resolved graph, because a manifest is where the edge is written and -//! where a reviewer will look for it. - -use std::fs; -use std::path::Path; +//! on no frontend of its own. +//! +//! # Two authorities, and the law stands on both +//! +//! **What the manifests DECLARE** is where a reviewer will look for an edge, and +//! it is read out of the decoded documents: key, resolved package identity, +//! declared path, edge kind, and any `target.` conditioning. Every Cargo +//! spelling arrives as one declaration, because the decoder resolved the +//! document before this law saw it. +//! +//! **What cargo NORMALIZES those manifests to** is a different question, and the +//! documents cannot answer it: an edge can arrive through workspace +//! inheritance, and a package identity is something only cargo settles. +//! `cargo metadata` is asked directly. +//! +//! The subject on that side is `packages[].dependencies` — each package's +//! dependency DECLARATIONS, normalized — and not `resolve.nodes[].deps`, which +//! is the graph one feature and target selection activates. This law is about +//! whether an edge EXISTS, so an optional edge nothing enables and an edge +//! conditioned on another platform are exactly the cases it must catch; reading +//! the selected graph would let either pass on a machine whose selection happens +//! not to activate it. +//! +//! Neither authority is a fallback for the other and neither is optional. If +//! cargo could not be asked, this law REFUSES: an absence nobody established is +//! not an absence, and "the core reaches no tooling" reported about a reading +//! that never happened is the exact silence this repository is eliminating. -use crate::repository::manifest::{Unread, dependency_declarations}; -use crate::repository::walk::{JUDGE_DIRECTORY, TOOLING_DIRECTORY}; +use crate::repository::cargo::{DeclaredDependency, MANIFEST_FILE, NormalizedWorkspace}; +use crate::repository::snapshot::{JUDGE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY}; /// The metaprogramming packages the core package may never reach. const TOOLING_PACKAGES: [&str; 2] = ["threadpak-macroc", "threadpak-macros"]; +/// The machine's own package. +const CORE_PACKAGE: &str = "threadpak"; + /// The services package, and the one manifest this law reads for the second /// absence. const SERVICES_MANIFEST: &str = "macros/macroc/Cargo.toml"; +/// The services package, as cargo names it. +const SERVICES_PACKAGE: &str = "threadpak-macroc"; + /// The Rust-facing expansion surface over the services. const FRONTEND_PACKAGE: &str = "threadpak-macros"; @@ -28,40 +55,59 @@ const FRONTEND_DIRECTORY: &str = "proc"; /// The qualification plane — the machine's judge. const JUDGE_PACKAGE: &str = "threadpak-testpak"; -/// The topology law, in two parts. +/// The topology law, in two parts and over two authorities. /// /// **Part one: the core never depends on tooling, and never on its judge.** The /// `threadpak` package carries no dependency edge to the metaprogramming /// tooling or to `testpak` under any Cargo edge kind. The edges run one way and /// inward — `macros/proc` → `macros/macroc` → `threadpak`, and `testpak` → /// everything — so the machine never depends on the tools that project its -/// contracts and never depends on the plane that judges it. Those lawful inward -/// edges live in the subsystem manifests; part one reads the ROOT manifest only, -/// where any such edge at all is a reversal of the topology. +/// contracts and never depends on the plane that judges it. /// /// **Part two: macroc never depends on its frontends.** A compiler service /// never depends on its frontend surfaces, EVEN FOR TESTS. So the services -/// manifest carries no edge to `threadpak-macros` under any kind either — a dev -/// edge is still an edge, and a composition test bought with one is the -/// participant grading itself. Composition is proven from outside the -/// participants, by the consumer fixture at `xtask/fixtures/macro-consumer`. +/// carry no edge to `threadpak-macros` under any kind either — a dev edge is +/// still an edge, and a composition test bought with one is the participant +/// grading itself. Composition is proven from outside the participants, by the +/// consumer fixture at `xtask/fixtures/macro-consumer`. /// -/// **Both parts refuse a manifest they cannot read.** An absence is only worth -/// reporting by a reader that would have seen the presence, so a manifest -/// written in a shape [`dependency_declarations`] does not enter is refused -/// here rather than passed. That is a third kind of offence and it says so in -/// its own words: nobody reached tooling, and nobody established that anybody -/// had not. Three shapes arrive that way — a dependency table written inline, a -/// value that does not close on the line that opens it, and a basic string -/// carrying an escape this reader does not decode and Cargo does — and each -/// carries the repair that shape takes, because they do not take the same one. -pub(crate) fn check_no_core_tooling_edge(root: &Path) -> Result<(), String> { - let manifest = - fs::read_to_string(root.join("Cargo.toml")).map_err(|e| format!("Cargo.toml: {e}"))?; - let services = fs::read_to_string(root.join(SERVICES_MANIFEST)) - .map_err(|e| format!("{SERVICES_MANIFEST}: {e}"))?; - let mut reported = core_tooling_edge_violations(&manifest); - reported.extend(services_frontend_edge_violations(&services)); +/// **Both parts are asked of both authorities.** A declared edge is refused +/// where a manifest writes one; a normalized declaration is refused wherever +/// cargo reports one, whatever the manifests happen to spell. An edge arriving +/// through workspace inheritance is invisible to the first and plain to the +/// second, which is why the second is not optional and why an unavailable +/// reading refuses rather than passes. +pub(crate) fn check_no_core_tooling_edge(snapshot: &RepositorySnapshot) -> Result<(), String> { + let census = snapshot.cargo().census(); + let mut reported: Vec = census + .of(MANIFEST_FILE) + .into_iter() + .filter_map(judge_core_declaration) + .map(|violation| format!("core package reaches tooling or its judge: {violation}")) + .collect(); + reported.extend( + census + .of(SERVICES_MANIFEST) + .into_iter() + .filter_map(judge_services_declaration) + .map(|violation| format!("services reach their expansion surface: {violation}")), + ); + let normalized = snapshot + .cargo() + .normalized() + .required("what cargo normalized this workspace's manifests to")?; + reported.extend(normalized_offences( + normalized, + CORE_PACKAGE, + judge_core_package, + "core package reaches tooling or its judge, as cargo normalized the manifests", + )?); + reported.extend(normalized_offences( + normalized, + SERVICES_PACKAGE, + judge_services_package, + "services reach their expansion surface, as cargo normalized the manifests", + )?); if reported.is_empty() { Ok(()) } else { @@ -69,269 +115,228 @@ pub(crate) fn check_no_core_tooling_edge(root: &Path) -> Result<(), String> { } } -/// Every offence the root manifest commits: one description per tooling edge, -/// then one per dependency table the reader could not enter. +/// The violation one DECLARED entry of the root manifest commits, if any. /// /// An entry's PACKAGE IDENTITY is its `package = "…"` key when it carries one -/// and its own key otherwise, and an entry is a violation when that identity -/// names a tooling package or the judge, or when its `path` points into the -/// tooling subsystem directory or the judge's directory. Renaming therefore -/// hides nothing, and neither does spelling: the reader resolves every Cargo -/// spelling of an entry to the key path Cargo itself resolves it to, so the -/// identity judged here is the package Cargo would build against rather than -/// the text a line happened to start with. -/// -/// Each offence carries its own claim rather than a shared prefix, because the -/// two kinds are not the same finding: one says an edge is there, the other -/// says this law could not look. -fn core_tooling_edge_violations(manifest_text: &str) -> Vec { - let declared = dependency_declarations(manifest_text); - let mut found: Vec = declared - .entries - .into_iter() - .filter_map(|(kind, key, package, path)| { - judge_dependency(kind, &key, package.as_deref(), path.as_deref()) - }) - .map(|violation| format!("core package reaches tooling or its judge: {violation}")) - .collect(); - found.extend(declared.unread.iter().map(|shape| unread("core", shape))); - found +/// and its own key otherwise, which is Cargo's own rule — so renaming hides +/// nothing. Neither does spelling: the decoder resolved every Cargo spelling of +/// an entry to one declaration before this reading saw it. +fn judge_core_declaration(entry: &DeclaredDependency) -> Option { + let identity = entry.identity(); + if TOOLING_PACKAGES.contains(&identity) || identity == JUDGE_PACKAGE { + return Some(format!("{entry} resolves to package `{identity}`")); + } + let path = entry.path()?; + if points_into(path, TOOLING_DIRECTORY) { + return Some(format!( + "{entry} has path `{path}` inside `{TOOLING_DIRECTORY}/`" + )); + } + if points_into(path, JUDGE_DIRECTORY) { + return Some(format!( + "{entry} has path `{path}` inside `{JUDGE_DIRECTORY}/`" + )); + } + None } -/// Every offence the services manifest commits, read exactly like the core -/// manifest: package identity first, so a renamed entry betrays itself, then -/// the declared path, so an entry named anything at all that reaches into -/// `macros/proc/` is caught by where it points, then the tables the reader -/// could not enter. -fn services_frontend_edge_violations(manifest_text: &str) -> Vec { - let declared = dependency_declarations(manifest_text); - let mut found: Vec = declared - .entries - .into_iter() - .filter_map(|(kind, key, package, path)| { - judge_frontend_dependency(kind, &key, package.as_deref(), path.as_deref()) - }) - .map(|violation| format!("services reach their expansion surface: {violation}")) - .collect(); - found.extend( - declared - .unread - .iter() - .map(|shape| unread("services", shape)), - ); - found +/// The violation one DECLARED entry of the services manifest commits, if any. +fn judge_services_declaration(entry: &DeclaredDependency) -> Option { + if entry.identity() == FRONTEND_PACKAGE { + return Some(format!("{entry} resolves to package `{FRONTEND_PACKAGE}`")); + } + let path = entry.path()?; + if points_into(path, FRONTEND_DIRECTORY) { + return Some(format!( + "{entry} has path `{path}` inside `{FRONTEND_DIRECTORY}/`" + )); + } + None } -/// The offence one unreadable declaration commits. +/// Every offence one package's NORMALIZED declarations commit. /// -/// Not an edge — a manifest this law cannot read. What it declares sits inside a -/// value, and this law reads lines, so the absence it would otherwise report -/// about such a manifest is an absence nobody established. An unreadable -/// declaration is refused rather than passed, which is the difference between a -/// law and a habit, and the repair is in the message because the repair is one -/// line either way. Which one line differs, so each shape says its own. -fn unread(manifest: &str, shape: &Unread) -> String { - match *shape { - Unread::InlineTable(ref spelling) => format!( - "the {manifest} manifest declares `{spelling}` as an inline table, and the entries \ - inside it are not read here: spell it as `[{spelling}]` with its entries on their own \ - lines, so this law can see what it is being asked to allow" - ), - Unread::MultiLineValue(ref spelling) => format!( - "the {manifest} manifest gives `{spelling}` a value that does not close on the line \ - that opens it, and every line it spans is read here as structure rather than as the \ - data it is: put the value on one line, so this law cannot be told a dependency table \ - ended where a string, a list, or an inline table merely continued" - ), - Unread::EscapedValue(ref spelling) => format!( - "the {manifest} manifest gives `{spelling}` a basic string carrying a `\\` escape, \ - which Cargo decodes before it resolves anything and this law does not decode at all, \ - so `\\u002D` reads here as six characters where Cargo reads a `-`: write the value \ - with the characters it means, or write it as a literal string in single quotes, \ - which TOML leaves undecoded and this law reads as it stands" - ), +/// The normalized reading judges package IDENTITY and nothing else. A path +/// cargo reports is absolute — it names where a checkout happens to sit — so +/// reading a directory out of one would make this law depend on what somebody +/// called the folder they cloned into. The declared reading is where a path is +/// judged, because a declared path is relative and is what a manifest actually +/// states. +fn normalized_offences( + normalized: &NormalizedWorkspace, + package: &str, + judge: fn(&str) -> Option, + claim: &str, +) -> Result, String> { + let found = normalized + .package(package) + .taken(&format!("the package `{package}` in what cargo normalized"))?; + let mut offences = Vec::new(); + for edge in found.declarations() { + let Some(violation) = judge(edge.package()) else { + continue; + }; + let kind = edge.kind().taken("the edge kind cargo reported")?; + let conditioned = match edge.target() { + Some(predicate) => format!(" under `target.{predicate}`"), + None => String::new(), + }; + offences.push(format!( + "{claim}: [{kind}] `{}`{conditioned} {violation}", + edge.key() + )); } + Ok(offences) } -/// The violation one dependency entry of the ROOT manifest commits, if any. -fn judge_dependency( - kind: &str, - key: &str, - package: Option<&str>, - path: Option<&str>, -) -> Option { - let identity = package.unwrap_or(key); +/// The violation one package the CORE declares an edge to commits, if any. +fn judge_core_package(identity: &str) -> Option { if TOOLING_PACKAGES.contains(&identity) || identity == JUDGE_PACKAGE { - return Some(format!("[{kind}] `{key}` resolves to package `{identity}`")); - } - if let Some(path) = path { - if points_into(path, TOOLING_DIRECTORY) { - return Some(format!( - "[{kind}] `{key}` has path `{path}` inside `{TOOLING_DIRECTORY}/`" - )); - } - if points_into(path, JUDGE_DIRECTORY) { - return Some(format!( - "[{kind}] `{key}` has path `{path}` inside `{JUDGE_DIRECTORY}/`" - )); - } + Some(format!("resolves to package `{identity}`")) + } else { + None } - None } -/// The violation one dependency entry of the SERVICES manifest commits, if any. -fn judge_frontend_dependency( - kind: &str, - key: &str, - package: Option<&str>, - path: Option<&str>, -) -> Option { - let identity = package.unwrap_or(key); +/// The violation one package the SERVICES declare an edge to commits, if any. +fn judge_services_package(identity: &str) -> Option { if identity == FRONTEND_PACKAGE { - return Some(format!( - "[{kind}] `{key}` resolves to package `{FRONTEND_PACKAGE}`" - )); - } - if let Some(path) = path - && points_into(path, FRONTEND_DIRECTORY) - { - return Some(format!( - "[{kind}] `{key}` has path `{path}` inside `{FRONTEND_DIRECTORY}/`" - )); + Some(format!("resolves to package `{FRONTEND_PACKAGE}`")) + } else { + None } - None } -/// Whether a dependency path enters one named directory. The segment is matched -/// wherever it appears, so `../proc`, `macros/proc`, and any longer detour that -/// lands there are all the same edge. +/// Whether a declared dependency path enters one named directory. The segment is +/// matched wherever it appears, so `../proc`, `macros/proc`, and any longer +/// detour that lands there are all the same edge. fn points_into(path: &str, directory: &str) -> bool { path.replace('\\', "/") .split('/') .any(|segment| segment == directory) } -/// Planted reversals for a law whose subject is text rather than the tree. +/// Planted reversals for a law whose declared half is text. /// -/// A check that cannot fail is not a check. Each reversal here hands -/// [`core_tooling_edge_violations`] or [`services_frontend_edge_violations`] a -/// synthetic manifest that reverses the topology one Cargo edge kind at a time -/// and proves the reversal is caught. The manifests are written here rather than -/// on disk: the law is proven against fixture text, never by dirtying the -/// repository it guards. +/// A check that cannot fail is not a check. Each reversal hands the DECLARED +/// reading a synthetic manifest that reverses the topology one Cargo edge kind +/// at a time and proves the reversal is caught. The manifests are written here +/// rather than on disk: the law is proven against fixture text, never by +/// dirtying the repository it guards. +/// +/// The spellings are no longer the subject. Eleven of these reversals used to +/// exist because a line reader had to be taught one spelling at a time; the +/// decoder resolves them all to one declaration, and where every spelling of one +/// declaration lands identically is proven once, in `repository::cargo`, rather +/// than eleven times here. #[cfg(test)] mod tests { - use super::{ - SERVICES_MANIFEST, core_tooling_edge_violations, services_frontend_edge_violations, - }; - use crate::repository::walk::repo_root; - use std::fs; - use std::path::PathBuf; - - /// The manifest preamble every core fixture shares: the core package itself. - const PREAMBLE: &str = "[package]\nname = \"threadpak\"\n\n"; - - /// The manifest preamble every services fixture shares. - const SERVICES_PREAMBLE: &str = "[package]\nname = \"threadpak-macroc\"\n\n"; - - /// The violations a fixture manifest commits, preamble supplied. - fn violations(body: &str) -> Vec { - core_tooling_edge_violations(&format!("{PREAMBLE}{body}")) + use super::{check_no_core_tooling_edge, judge_core_declaration, judge_services_declaration}; + use crate::repository::cargo::dependency_declarations; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; + + /// The violations a fixture manifest commits, as the CORE manifest. + fn violations(body: &str) -> Result, String> { + Ok(entries(body)? + .iter() + .filter_map(judge_core_declaration) + .collect()) + } + + /// The violations a fixture manifest commits, as the SERVICES manifest. + fn services_violations(body: &str) -> Result, String> { + Ok(entries(body)? + .iter() + .filter_map(judge_services_declaration) + .collect()) } - /// The frontend violations a services fixture manifest commits. - fn services_violations(body: &str) -> Vec { - services_frontend_edge_violations(&format!("{SERVICES_PREAMBLE}{body}")) + /// The declared entries of one fixture manifest. + fn entries(body: &str) -> Result, String> { + let document = body + .parse::() + .map_err(|error| format!("fixture manifest does not decode: {error}"))?; + Ok(dependency_declarations( + &CanonicalPath::spelled("Cargo.toml"), + &document, + )) } /// Reversal (a): the plain edge — a normal dependency on the services. #[test] - fn a_normal_tooling_dependency_is_a_violation() { - let found = violations("[dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n"); + fn a_normal_tooling_dependency_is_a_violation() -> Result<(), String> { + let found = + violations("[dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n")?; assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); + Ok(()) } /// Reversal (b): the disguised edge — the package renamed at the key, so /// only the resolved package identity betrays it. #[test] - fn a_renamed_tooling_dependency_is_a_violation() { + fn a_renamed_tooling_dependency_is_a_violation() -> Result<(), String> { let found = violations( "[dependencies]\nhelpers = { package = \"threadpak-macroc\", version = \"0.0.0\" }\n", - ); + )?; assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); + Ok(()) } - /// Reversal (c): the test-only edge. - #[test] - fn a_tooling_dev_dependency_is_a_violation() { - let found = - violations("[dev-dependencies]\nthreadpak-macros = { path = \"macros/proc\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("dev-dependencies"))); - } - - /// Reversal (d): the build-script edge. + /// Reversal (c): the test-only edge, and (d) the build-script edge. A dev + /// edge is still an edge and a build edge is still an edge. #[test] - fn a_tooling_build_dependency_is_a_violation() { - let found = - violations("[build-dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("build-dependencies"))); + fn a_dev_or_build_tooling_dependency_is_a_violation() -> Result<(), String> { + let dev = + violations("[dev-dependencies]\nthreadpak-macros = { path = \"macros/proc\" }\n")?; + assert_eq!(dev.len(), 1, "{dev:?}"); + assert!(dev.iter().any(|v| v.contains("dev-dependencies"))); + let build = + violations("[build-dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n")?; + assert_eq!(build.len(), 1, "{build:?}"); + assert!(build.iter().any(|v| v.contains("build-dependencies"))); + Ok(()) } /// Reversal (e): the platform-conditional edge, which no scan of the three /// bare tables would ever see. #[test] - fn a_target_specific_tooling_dependency_is_a_violation() { + fn a_target_specific_tooling_dependency_is_a_violation() -> Result<(), String> { let found = violations( "[target.'cfg(unix)'.dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n", - ); + )?; assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); + Ok(()) } /// Reversal (f): the path edge — a dependency named anything at all whose /// path reaches into the tooling subsystem directory. #[test] - fn a_path_into_the_tooling_directory_is_a_violation() { - let found = violations("[dependencies]\nhelpers = { path = \"macros/macroc\" }\n"); + fn a_path_into_the_tooling_directory_is_a_violation() -> Result<(), String> { + let found = violations("[dependencies]\nhelpers = { path = \"macros/macroc\" }\n")?; assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("macros/"))); + Ok(()) } - /// Reversal (g): the judge edge — the machine taking an ordinary dependency - /// on the plane that judges it. Production never depends on its judge. - #[test] - fn a_core_dependency_on_the_judge_is_a_violation() { - let found = violations("[dependencies]\nthreadpak-testpak = { path = \"testpak\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("threadpak-testpak"))); - } - - /// Reversal (h): the judge edge bought for tests only — the shape a "just - /// for the test harness" edge actually takes. - #[test] - fn a_core_dev_dependency_on_the_judge_is_a_violation() { - let found = violations("[dev-dependencies]\nthreadpak-testpak = { path = \"testpak\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("dev-dependencies"))); - } - - /// Reversal (i): the disguised judge edge — renamed at the key, and - /// separately an entry named anything at all whose path reaches into the - /// judge's directory. + /// Reversal (g)–(i): the judge edges — ordinary, test-only, renamed, and by + /// path. Production never depends on its judge, and "just for the test + /// harness" is the shape that edge actually takes. #[test] - fn a_renamed_or_path_edge_to_the_judge_is_a_violation() { - let renamed = violations( + fn any_edge_to_the_judge_is_a_violation() -> Result<(), String> { + for body in [ + "[dependencies]\nthreadpak-testpak = { path = \"testpak\" }\n", + "[dev-dependencies]\nthreadpak-testpak = { path = \"testpak\" }\n", "[dependencies]\nharness = { package = \"threadpak-testpak\", version = \"0.0.0\" }\n", - ); - assert_eq!(renamed.len(), 1, "{renamed:?}"); - assert!(renamed.iter().any(|v| v.contains("threadpak-testpak"))); - let by_path = violations("[dependencies]\nharness = { path = \"testpak\" }\n"); - assert_eq!(by_path.len(), 1, "{by_path:?}"); - assert!(by_path.iter().any(|v| v.contains("testpak/"))); + "[dependencies]\nharness = { path = \"testpak\" }\n", + ] { + let found = violations(body)?; + assert_eq!(found.len(), 1, "{body} -> {found:?}"); + } + Ok(()) } /// Reversal (j): the DOTTED edge — workspace inheritance written @@ -342,389 +347,49 @@ mod tests { /// `threadpak-macroc.workspace`, which matches no package, so the edge /// passed. The repository answered that with a comment in the root manifest /// telling authors never to use the dotted spelling — and a prose "never" - /// is not an invariant. This is the invariant. - #[test] - fn a_dotted_workspace_inheritance_is_a_violation() { - let found = violations("[dependencies]\nthreadpak-macroc.workspace = true\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); - } - - /// Reversal (k): the same dotted shape carrying the fields that hide an - /// edge — `name.package` and `name.path` — and spelled across several - /// lines, which is how a dotted entry is usually written. - /// - /// The lines of one dotted entry accumulate into the one entry they - /// declare, so a renamed edge is caught once rather than reported twice or - /// missed entirely because its identity and its version sat on different - /// lines. - #[test] - fn a_dotted_entry_spelled_across_lines_is_one_violation() { - let renamed = violations( - "[dependencies]\nhelpers.version = \"0.0.0\"\nhelpers.package = \"threadpak-macroc\"\n", - ); - assert_eq!(renamed.len(), 1, "{renamed:?}"); - assert!(renamed.iter().any(|v| v.contains("threadpak-macroc"))); - let by_path = violations("[dependencies]\nhelpers.path = \"macros/macroc\"\n"); - assert_eq!(by_path.len(), 1, "{by_path:?}"); - assert!(by_path.iter().any(|v| v.contains("macros/"))); - } - - /// Reversal (l): the quoted key, under either TOML quote, and quoted at the - /// head of a dotted key. A package name never needs quoting, which is - /// exactly why quoting it was somewhere to hide. - #[test] - fn a_quoted_entry_key_is_a_violation() { - let literal = violations("[dependencies]\n'threadpak-macroc' = { version = \"0.0.0\" }\n"); - assert_eq!(literal.len(), 1, "{literal:?}"); - assert!(literal.iter().any(|v| v.contains("threadpak-macroc"))); - let dotted = violations("[dependencies]\n\"threadpak-macroc\".workspace = true\n"); - assert_eq!(dotted.len(), 1, "{dotted:?}"); - assert!(dotted.iter().any(|v| v.contains("threadpak-macroc"))); - } - - /// Reversal (m): the quoted TABLE header. Every segment of a key path may - /// be quoted, including the ones naming the edge kind and the `target` - /// prefix, and a header nobody recognized used to close the table rather - /// than open it — which left every entry beneath it unread. - #[test] - fn a_quoted_table_header_is_a_violation() { - let quoted_kind = - violations("[\"dependencies\"]\nthreadpak-macroc = { version = \"0.0.0\" }\n"); - assert_eq!(quoted_kind.len(), 1, "{quoted_kind:?}"); - assert!(quoted_kind.iter().any(|v| v.contains("threadpak-macroc"))); - let quoted_target = violations( - "[\"target\".'cfg(unix)'.\"dev-dependencies\"]\n\ - threadpak-macroc = { version = \"0.0.0\" }\n", - ); - assert_eq!(quoted_target.len(), 1, "{quoted_target:?}"); - assert!(quoted_target.iter().any(|v| v.contains("dev-dependencies"))); - } - - /// Reversal (n): the table named by the KEY rather than by a header, which - /// is what a dotted key written before any header declares. There is no - /// `[dependencies]` line anywhere in this manifest and the edge is there - /// all the same. - #[test] - fn a_dotted_table_key_before_any_header_is_a_violation() { - let found = core_tooling_edge_violations( - "dependencies.threadpak-macroc.workspace = true\n\n[package]\nname = \"threadpak\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); - } - - /// Reversal (o): the sub-table form, `[KIND.name]` with its fields beneath - /// it, plainly and under a `target.'…'` prefix and with the identity - /// renamed. The reader has always read this shape; nothing had ever proven - /// it, so it stood on a reading rather than on a reversal. - #[test] - fn a_sub_table_dependency_is_a_violation() { - let plain = violations("[dependencies.threadpak-macroc]\nversion = \"0.0.0\"\n"); - assert_eq!(plain.len(), 1, "{plain:?}"); - assert!(plain.iter().any(|v| v.contains("threadpak-macroc"))); - let renamed = violations("[dependencies.helpers]\npackage = \"threadpak-macroc\"\n"); - assert_eq!(renamed.len(), 1, "{renamed:?}"); - assert!(renamed.iter().any(|v| v.contains("threadpak-macroc"))); - let conditional = violations( - "[target.'cfg(unix)'.dev-dependencies.threadpak-macroc]\nversion = \"0.0.0\"\n", - ); - assert_eq!(conditional.len(), 1, "{conditional:?}"); - assert!(conditional.iter().any(|v| v.contains("dev-dependencies"))); - } - - /// Reversal (p): the literal-string VALUE. TOML's two string forms are one - /// value, so a path written in single quotes is the same edge as a path - /// written in double quotes, and reading only one of them read only half - /// the declarations a path can be written in. - #[test] - fn a_literal_string_path_is_a_violation() { - let found = violations("[dependencies]\nhelpers = { path = 'macros/macroc' }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("macros/"))); - } - - /// Reversal (q): a comment after the table header. The header still opens - /// the table, so the entries beneath it are still read — a comment is not a - /// place to put a dependency table. + /// is not an invariant. The decoder is the invariant. #[test] - fn a_header_carrying_a_comment_still_opens_its_table() { - let found = violations( - "[dependencies] # inherited from the workspace\n\ - threadpak-macroc = { version = \"0.0.0\" }\n", - ); + fn a_dotted_workspace_inheritance_is_a_violation() -> Result<(), String> { + let found = violations("[dependencies]\nthreadpak-macroc.workspace = true\n")?; assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("threadpak-macroc"))); + Ok(()) } - /// Reversal (r): the dependency table written as an INLINE table, whose - /// entries sit inside one line's value. + /// Reversal (r), and it is no longer a ceiling: the dependency table written + /// as an INLINE table. /// - /// This one is refused rather than read, and the refusal is the point. A - /// line-oriented reader does not enter a value, so what it would report - /// about such a manifest is an absence it never established. The law says - /// so and fails, which is what separates a ceiling that is written down - /// from a ceiling that is enforced. - #[test] - fn an_inline_dependency_table_is_refused_unread() { - let at_root = core_tooling_edge_violations( - "dependencies = { threadpak-macroc = { path = \"macros/macroc\" } }\n\n\ - [package]\nname = \"threadpak\"\n", - ); + /// A line-oriented reader does not enter a value, so it could not read this + /// at all, and the honest answer it had was to REFUSE the manifest as + /// unread. The decoder enters the value, so the edge inside is now the edge + /// it always was — a violation, named as one, rather than a manifest this + /// law could not look at. + #[test] + fn an_inline_dependency_table_is_read_as_the_edge_it_carries() -> Result<(), String> { + let at_root = + violations("dependencies = { threadpak-macroc = { path = \"macros/macroc\" } }\n")?; assert_eq!(at_root.len(), 1, "{at_root:?}"); - assert!(at_root.iter().any(|v| v.contains("inline table"))); - let under_target = violations( - "[target.'cfg(unix)']\ndependencies = { threadpak-macroc = { version = \"0.0.0\" } }\n", - ); - assert_eq!(under_target.len(), 1, "{under_target:?}"); - assert!(under_target.iter().any(|v| v.contains("inline table"))); - let whole_tree = core_tooling_edge_violations( + assert!(at_root.iter().any(|v| v.contains("threadpak-macroc"))); + let whole_tree = violations( "target = { 'cfg(unix)' = { dependencies = { threadpak-macroc = { version = \ - \"0.0.0\" } } } }\n\n[package]\nname = \"threadpak\"\n", - ); + \"0.0.0\" } } } }\n", + )?; assert_eq!(whole_tree.len(), 1, "{whole_tree:?}"); - assert!(whole_tree.iter().any(|v| v.contains("inline table"))); - } - - /// Reversal (s): THE bypass, in the shape it was executed in. - /// - /// A dependency sub-table carries a multi-line basic string whose body - /// holds one line shaped like a table header, and the rename is written - /// AFTER the string. MEASURED on cargo 1.97.1 against the real root - /// manifest, with this law run as its own compiled binary: cargo accepts - /// the manifest, `cargo tree` resolves `threadpak → threadpak-macroc`, and - /// the law printed PASS — because by the time the reader reached `package` - /// it believed the dependency table had ended three lines earlier. - /// - /// Nothing about the edge is read now, and that is the point: the offence - /// is the unreadable value, reported before the reader can be lied to - /// about what follows it. - #[test] - fn a_multi_line_string_inside_a_dependency_table_is_refused_unread() { - let found = violations( - "[dev-dependencies.helpers]\nversion = \"0.0.0\"\nmetadata = \"\"\"\n\ - this string contains a line that looks like a header\n[package]\n\ - and keeps going\n\"\"\"\npackage = \"threadpak-macroc\"\n\ - path = \"macros/macroc\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found - .iter() - .any(|v| v.contains("dev-dependencies.helpers.metadata") - && v.contains("does not close on the line that opens it")), - "{found:?}" - ); - } - - /// Reversal (t): the same bypass in TOML's other multi-line string. Two - /// spellings of one construct are one construct, and reading only the - /// basic one would have left the literal one open. - #[test] - fn a_multi_line_literal_string_inside_a_dependency_table_is_refused_unread() { - let found = violations( - "[dev-dependencies.helpers]\nmetadata = '''\n[package]\n'''\n\ - package = \"threadpak-macroc\"\npath = \"macros/macroc\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found.iter().any(|v| v.contains("does not close")), - "{found:?}" - ); - } - - /// Reversal (u): the bracket list broken across lines, one of whose - /// elements is itself a list and so occupies a line shaped like a header. - /// Measured accepted by cargo and resolving the same edge. - #[test] - fn a_multi_line_list_inside_a_dependency_table_is_refused_unread() { - let found = violations( - "[dev-dependencies.helpers]\nmetadata = [\n[1]\n]\n\ - package = \"threadpak-macroc\"\npath = \"macros/macroc\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found.iter().any(|v| v.contains("does not close")), - "{found:?}" - ); - } - - /// Reversal (v): the inline table broken across lines. Cargo takes it, and - /// its continuation lines are read here as fresh entries of the enclosing - /// table, so the entry that actually carries the rename is left holding - /// nothing but its own innocent key. - #[test] - fn an_inline_table_broken_across_lines_is_refused_unread() { - let found = violations( - "[dev-dependencies]\nhelpers = { version = \"0.0.0\",\n\ - package = \"threadpak-macroc\",\npath = \"macros/macroc\" }\n", - ); - assert!( - found - .iter() - .any(|v| v.contains("dev-dependencies.helpers") && v.contains("does not close")), - "{found:?}" - ); - } - - /// Reversal (w): the spanning value that is nowhere near a dependency - /// table, which is why the refusal is on every line rather than on the - /// lines inside one. - /// - /// The string sits at the top of the file and quotes a header. Everything - /// written after it is read at that phantom table, so the dotted - /// dependency keys below resolve for cargo — measured accepted, resolving - /// `threadpak → threadpak-macroc` — and reach no law here at all. - #[test] - fn a_spanning_value_outside_any_dependency_table_is_refused_unread() { - let found = core_tooling_edge_violations( - "notes = \"\"\"\n[x]\n\"\"\"\n\ - dev-dependencies.helpers.package = \"threadpak-macroc\"\n\ - dev-dependencies.helpers.path = \"macros/macroc\"\n\n\ - [package]\nname = \"threadpak\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("`notes`")), "{found:?}"); - } - - /// Reversal (x): the second seat reads the same refusal. Both halves of - /// this law stand on one manifest reader, and that is a claim, so it is - /// executed rather than asserted. - #[test] - fn a_spanning_value_in_the_services_manifest_is_refused_unread() { - let found = services_violations( - "[dev-dependencies.shell]\nmetadata = \"\"\"\n[package]\n\"\"\"\n\ - package = \"threadpak-macros\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found.iter().any(|v| v.contains("does not close")), - "{found:?}" - ); - } - - /// The direction the refusal is allowed to be wrong in, stated by what it - /// does NOT refuse: every value here closes on the line that opens it, so - /// every one of them is read. - /// - /// The single-line `"""…"""` form is a self-contained value and passes; a - /// `#` inside a literal string is content and does not end the line; a `\"` - /// inside a basic string does not close it early; and a one-line inline - /// table holding a bracket list nests in and back out to nothing. The - /// declared `path` is read off the last of them, which is how this control - /// proves the reader still WORKS rather than merely stays quiet. - #[test] - fn a_value_that_closes_on_its_line_is_read_rather_than_refused() { - let found = violations( - "[dependencies]\nserde = { version = \"1\", features = [\"derive\", \"std\"] }\n\n\ - [dependencies.helpers]\nmetadata = \"\"\"one line\"\"\"\n\ - notes = 'a # not a comment'\n\ - path = 'vendor/helpers'\n", - ); - assert!(found.is_empty(), "{found:?}"); - let reached = violations( - "[dependencies.helpers]\nmetadata = \"\"\"one line\"\"\"\n\ - path = 'macros/macroc'\n", - ); - assert_eq!(reached.len(), 1, "{reached:?}"); - assert!(reached.iter().any(|v| v.contains("macros/")), "{reached:?}"); - } - - /// Reversal (y): the escape sequence inside a value this law compares by - /// TEXT — a different mechanism from every reversal above, and the second - /// hole this reader was measured falling into. - /// - /// MEASURED on cargo 1.97.1 against the real root manifest with the gate run - /// as its own compiled binary: cargo decodes both escapes, `cargo tree -e - /// dev` resolves `threadpak -> threadpak-macroc`, and the law printed PASS. - /// No line lies about being structure here; the reader compared the text of - /// a value against names Cargo compares the DECODED value against, and - /// `\u002D` is six characters here and a `-` there. - /// - /// The escape is refused wherever it sits and whatever it spells, including - /// where the old normalization happened to catch it anyway: `\u002F` in a - /// path used to be turned into a separator by the same pass that reads - /// `..\macros` as a Windows spelling, which caught the edge by luck rather - /// than by law, and luck is not what an absence may rest on. - #[test] - fn a_basic_string_carrying_an_escape_is_refused_unread() { - let hidden = violations( - "[dev-dependencies]\nhelpers = { package = \"threadpak\\u002Dmacroc\", \ - path = \"\\u006Dacros/macroc\" }\n", - ); - assert_eq!(hidden.len(), 1, "{hidden:?}"); - assert!( - hidden - .iter() - .any(|v| v.contains("dev-dependencies.helpers") && v.contains("escape")), - "{hidden:?}" - ); - let lucky = violations( - "[dev-dependencies]\nhelpers = { package = \"threadpak-macroc\", \ - path = \"macros\\u002Fmacroc\" }\n", - ); - assert!(lucky.iter().any(|v| v.contains("escape")), "{lucky:?}"); - } - - /// The refusal knows which shape it found, which is the whole reason there - /// are two messages: an escaped value that CLOSES on its line is reported as - /// an escape and never as a value that spans lines. - /// - /// This is what the escape-honouring inside the string skipper buys now that - /// an escape is refused on its own account. A skipper that stopped at the - /// escaped quote would think the string ran off the end of the line, and - /// would send the author to put a value on one line it is already on. - #[test] - fn an_escaped_value_that_closes_is_reported_as_an_escape() { - let found = violations("[dependencies.helpers]\nnotes = \"a \\\" b\"\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("escape")), "{found:?}"); - assert!( - !found.iter().any(|v| v.contains("does not close")), - "{found:?}" - ); + Ok(()) } - /// The direction the escape refusal is allowed to be wrong in, stated by - /// what it does NOT refuse: a LITERAL string decodes nothing, so a `\` in - /// one is a backslash to TOML, to Cargo, and here. - /// - /// MEASURED, both halves. `path = 'macros\macroc'` is the lawful Windows - /// spelling of a path: cargo accepts it and resolves the edge, and it is - /// caught here as the edge it is — refusing it would be a false refusal on - /// something nobody wrote to hide anything. And there is nothing to hide - /// there: `package = 'threadpak\u002Dmacroc'` is refused by CARGO ITSELF - /// with `invalid character \ in package name`, because no decoding ever - /// turned those six characters into a `-`. - #[test] - fn a_literal_string_carrying_a_backslash_is_read_rather_than_refused() { - let found = violations( - "[dev-dependencies]\nhelpers = { package = 'threadpak-macroc', \ - path = 'macros\\macroc' }\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); - assert!( - found.iter().any(|v| v.contains("threadpak-macroc")), - "{found:?}" - ); - assert!(!found.iter().any(|v| v.contains("escape")), "{found:?}"); - } /// The positive control: a manifest with ordinary edges and none to the /// tooling or the judge is clean, so the law reports something real rather /// than everything. - /// - /// The dotted spellings are here too, on lawful entries. The reader that - /// now refuses `threadpak-macroc.workspace = true` reads - /// `serde.workspace = true` as the ordinary declaration it is: what changed - /// is which package a key resolves to, not which spellings are allowed. #[test] - fn a_manifest_without_tooling_edges_is_clean() { + fn a_manifest_without_tooling_edges_is_clean() -> Result<(), String> { let found = violations( "[dependencies]\nserde = \"1\"\ntokio.workspace = true\ntokio.features = [\"rt\"]\n\n\ [dev-dependencies]\ntrybuild = { version = \"1\" }\n\n\ [target.'cfg(windows)'.dependencies]\nwindows-sys = { version = \"0\" }\n", - ); + )?; assert!(found.is_empty(), "{found:?}"); + Ok(()) } /// The workspace's declaration POOL is not an edge, and this is the line @@ -734,99 +399,59 @@ mod tests { /// it is a dependency of the core package, so naming the tooling there is /// lawful — testpak inherits from that table and is supposed to. The edge /// exists when `[dependencies]` asks for the inheritance, which is the - /// second half below. A law that read a kind wherever the word appeared - /// would refuse the pool and be wrong; a law that read the key path only - /// where Cargo resolves one refuses the ask and is right. + /// second half below. #[test] - fn a_workspace_declaration_pool_is_not_an_edge() { + fn a_workspace_declaration_pool_is_not_an_edge() -> Result<(), String> { let pool = violations( "[workspace.dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n", - ); + )?; assert!(pool.is_empty(), "{pool:?}"); let asked = violations( "[dependencies]\nthreadpak-macroc.workspace = true\n\n\ [workspace.dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n", - ); + )?; assert_eq!(asked.len(), 1, "{asked:?}"); - assert!(asked.iter().any(|v| v.contains("threadpak-macroc"))); - } - - /// The law judges the ROOT manifest, and the root manifest holds. The - /// lawful inward edges — `macros/macroc` on the machine, `macros/proc` on - /// `macros/macroc` — live in the subsystem manifests, which this law never - /// reads and which are not violations. - #[test] - fn the_real_root_manifest_carries_no_tooling_edge() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap_or_default(); - assert!(!manifest.is_empty(), "root Cargo.toml is unreadable"); - let found = core_tooling_edge_violations(&manifest); - assert!(found.is_empty(), "{found:?}"); - } - - /// Part-two reversal (a): the plain edge — the services taking an ordinary - /// dependency on the surface that is supposed to call THEM. - #[test] - fn a_services_dependency_on_the_frontend_is_a_violation() { - let found = - services_violations("[dependencies]\nthreadpak-macros = { path = \"../proc\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("threadpak-macros"))); - } - - /// Part-two reversal (b): the test-only edge — the exact shape the law was - /// written to kill, since a composition test bought with a dev edge is the - /// participant grading itself. - #[test] - fn a_services_dev_dependency_on_the_frontend_is_a_violation() { - let found = - services_violations("[dev-dependencies]\nthreadpak-macros = { path = \"../proc\" }\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("dev-dependencies"))); + Ok(()) } - /// Part-two reversal (c): the disguised edge — the surface renamed at the - /// key, and separately an entry named anything at all whose path reaches - /// into the surface's directory. + /// Part-two reversals: the services taking an edge on the surface that is + /// supposed to call THEM — plainly, for tests only, renamed, by path, and + /// through workspace inheritance. #[test] - fn a_renamed_services_dependency_on_the_frontend_is_a_violation() { - let renamed = services_violations( + fn any_services_edge_to_the_frontend_is_a_violation() -> Result<(), String> { + for body in [ + "[dependencies]\nthreadpak-macros = { path = \"../proc\" }\n", + "[dev-dependencies]\nthreadpak-macros = { path = \"../proc\" }\n", "[dev-dependencies]\nshell = { package = \"threadpak-macros\", version = \"0.0.0\" }\n", - ); - assert_eq!(renamed.len(), 1, "{renamed:?}"); - assert!(renamed.iter().any(|v| v.contains("threadpak-macros"))); - let by_path = services_violations("[dependencies]\nshell = { path = \"../proc\" }\n"); - assert_eq!(by_path.len(), 1, "{by_path:?}"); - assert!(by_path.iter().any(|v| v.contains("proc/"))); - } - - /// Part-two reversal (d): the dotted edge at the second seat. Both halves - /// of this law read one manifest reader, so a spelling caught at the core - /// manifest is caught here too — and that is a claim, so it is executed - /// rather than asserted. - #[test] - fn a_dotted_services_dependency_on_the_frontend_is_a_violation() { - let found = services_violations("[dependencies]\nthreadpak-macros.workspace = true\n"); - assert_eq!(found.len(), 1, "{found:?}"); - assert!(found.iter().any(|v| v.contains("threadpak-macros"))); + "[dependencies]\nshell = { path = \"../proc\" }\n", + "[dependencies]\nthreadpak-macros.workspace = true\n", + ] { + let found = services_violations(body)?; + assert_eq!(found.len(), 1, "{body} -> {found:?}"); + } + Ok(()) } /// The part-two positive control: the services depending only on the - /// machine are clean, so the law reports something real rather than - /// everything. + /// machine are clean. #[test] - fn a_services_manifest_without_a_frontend_edge_is_clean() { - let found = services_violations("[dependencies]\nthreadpak = { path = \"../..\" }\n"); + fn a_services_manifest_without_a_frontend_edge_is_clean() -> Result<(), String> { + let found = services_violations("[dependencies]\nthreadpak = { path = \"../..\" }\n")?; assert!(found.is_empty(), "{found:?}"); + Ok(()) } - /// Part two judges the real services manifest, and it holds. - #[test] - fn the_real_services_manifest_carries_no_frontend_edge() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let manifest = fs::read_to_string(root.join(SERVICES_MANIFEST)).unwrap_or_default(); - assert!(!manifest.is_empty(), "services Cargo.toml is unreadable"); - let found = services_frontend_edge_violations(&manifest); - assert!(found.is_empty(), "{found:?}"); + /// The real repository holds, on BOTH authorities at once. + /// + /// This is the only test here that reaches the resolved half, because the + /// resolved half is cargo's answer about a real workspace and cannot be + /// written as a fixture. It is also where the law's refusal-on-unknown is + /// exercised: a run that could not ask cargo fails here rather than + /// reporting an absence nobody established. + #[test] + fn the_real_workspace_carries_no_prohibited_edge() -> Result<(), String> { + let found = check_no_core_tooling_edge(repository_snapshot()?); + assert!(found.is_ok(), "{found:?}"); + Ok(()) } } diff --git a/xtask/src/checks/hygiene.rs b/xtask/src/checks/hygiene.rs index e5c9475..a31afb6 100644 --- a/xtask/src/checks/hygiene.rs +++ b/xtask/src/checks/hygiene.rs @@ -1,33 +1,47 @@ //! What the tree may spell. //! -//! Three laws that judge files and lines by what they are rather than by what -//! they agree with: LF line endings with no symlinks, no Python anywhere ever, -//! and no underscore-prefixed field carrying real data. They share one shape — -//! walk a tree, collect every offender, name them all in one refusal — because a -//! rule about every file is worth nothing if some corner of the tree is exempt -//! and worth little if it reports only the first hit. +//! Three laws that judge files and declarations by what they ARE rather than by +//! what they agree with: LF line endings with no symlinks, no Python anywhere +//! ever, and no underscore-prefixed field carrying real data. They share one +//! shape — read the whole population, collect every offender, name them all in +//! one refusal — because a rule about every file is worth nothing if some corner +//! of the tree is exempt and worth little if it reports only the first hit. -use std::fs; -use std::path::Path; +use crate::repository::snapshot::{ + JUDGE_DIRECTORY, MACHINE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY, +}; +use crate::repository::types::{CanonicalPath, LinkState}; -use crate::repository::walk::{JUDGE_DIRECTORY, TOOLING_DIRECTORY, visit_files}; +/// The marker type an underscore-prefixed field is lawful for. +const TYPE_LEVEL_MARKER: &str = "PhantomData"; + +/// The trees the field law scans: the machine, the tools that project its +/// contracts, and the plane that judges it. +const SCANNED_TREES: [&str; 3] = [MACHINE_DIRECTORY, TOOLING_DIRECTORY, JUDGE_DIRECTORY]; + +/// The directory the judge keeps its compiler fixtures under. +const JUDGE_FIXTURES: &str = "testpak/tests"; /// Every file in the repository is LF-only and nothing is a symlink. -pub(crate) fn check_lf_and_no_symlinks(root: &Path) -> Result<(), String> { +/// +/// Read off the one snapshot, so the population is the population every other +/// law is about. A file whose bytes could not be read REFUSES rather than +/// passing: whether it carries a carriage return is then unknown, and unknown is +/// not clean. +pub(crate) fn check_lf_and_no_symlinks(snapshot: &RepositorySnapshot) -> Result<(), String> { let mut offenders = Vec::new(); - visit_files(root, &mut |path| { - let metadata = - fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; - if metadata.file_type().is_symlink() { - offenders.push(format!("symlink: {}", path.display())); - return Ok(()); + for (path, fact) in snapshot.files().iter() { + match *fact.link().required(path.as_str())? { + LinkState::Symlink => { + offenders.push(format!("symlink: {path}")); + continue; + } + LinkState::RegularFile => (), } - let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - if bytes.contains(&b'\r') { - offenders.push(format!("CRLF: {}", path.display())); + if fact.bytes().required(path.as_str())?.contains(&b'\r') { + offenders.push(format!("CRLF: {path}")); } - Ok(()) - })?; + } if offenders.is_empty() { Ok(()) } else { @@ -36,14 +50,14 @@ pub(crate) fn check_lf_and_no_symlinks(root: &Path) -> Result<(), String> { } /// No Python exists in this repository, ever. -pub(crate) fn check_no_python(root: &Path) -> Result<(), String> { - let mut offenders = Vec::new(); - visit_files(root, &mut |path| { - if path.extension().is_some_and(|ext| ext == "py") { - offenders.push(path.display().to_string()); - } - Ok(()) - })?; +pub(crate) fn check_no_python(snapshot: &RepositorySnapshot) -> Result<(), String> { + let offenders: Vec = snapshot + .files() + .iter() + .map(|(path, _)| path) + .filter(|path| path.extension_is("py")) + .map(CanonicalPath::to_string) + .collect(); if offenders.is_empty() { Ok(()) } else { @@ -60,37 +74,72 @@ pub(crate) fn check_no_python(root: &Path) -> Result<(), String> { /// (`macros/`), and the qualification plane (`testpak/`): the tools that project /// the machine's contracts, and the plane that judges them, are held to the /// machine's own honesty about what a field carries. -pub(crate) fn check_underscore_fields_are_phantom(root: &Path) -> Result<(), String> { +/// +/// # What the reader establishes, and what it does not +/// +/// A FIELD is what this law is about, so a field is what it reads: `syn` hands +/// back the fields a source declares, with the type each one declares, and the +/// question is asked of those. The line scanner this replaced asked a different +/// question — whether a LINE began with an underscore and carried a colon — and +/// answered it about function parameters, local bindings, match arms, and +/// anything inside a string or a doc comment that happened to be shaped that +/// way, while a field written across two lines escaped it entirely. +/// +/// The question asked of the type is whether the field's OUTER declared type is +/// the marker, and not whether the marker appears somewhere inside it. The +/// difference is the whole claim: `_hidden: (u64, PhantomData)` mentions +/// the marker at depth and carries a readable `u64` beside it, which is the +/// suppressor idiom wearing the exemption's clothes. A field is lawful when what +/// it declares IS `PhantomData<…>` — under any spelling of the path that reaches +/// it, since `core::marker::PhantomData` and `PhantomData` are one type — +/// and a tuple, wrapper, reference, array, slice, or container merely holding +/// one is not. +/// +/// Three things a parse cannot reach, all failing CLOSED: a field written inside +/// a `macro_rules!` transcriber is not a field until it is expanded, and this +/// reader does not expand; a type alias that resolves TO `PhantomData` is not +/// recognized, because resolving an alias is the compiler's question rather than +/// a parse's; and a record declared inside an EXPRESSION — a block used as a +/// value, a closure body, a match arm — is not reached, because `syn::Expr` is +/// `#[non_exhaustive]` and a reader completing itself by listing that enum's +/// variants is complete only until the decoder gains one more. The first two +/// cost a lawful field a refusal it has to spell differently. The third is a +/// stated gap in the population rather than a refusal, and it closes when the +/// one reading gains a visitor that owns the whole grammar — not when this law +/// is taught one more shape. +/// +/// Everywhere a record CAN be declared as an item is entered: a module's body, +/// a free road's body, an implementation method's body, and a trait method's +/// default body. The walk once entered free roads alone, so a record declared +/// inside a method stood outside a population this law states repository-wide. +/// +/// # The one exclusion, and it is the judge's own +/// +/// A file BENEATH `testpak/tests/` is a fixture the judge feeds to a compiler on +/// purpose, and several of them are written not to compile — one of them does not +/// parse at all. Such a file declares no field this reader can see and no field +/// any build compiles, so it is outside this law's subject rather than a hole in +/// it. The top-level sources of `testpak/tests/` are real tests and are scanned; +/// the exclusion is exactly the narrowing `crate::checks::obligations` already +/// draws between a seat and a fixture, drawn here for the same reason. +pub(crate) fn check_underscore_fields_are_phantom( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { let mut offenders = Vec::new(); - let mut inspect = |path: &Path| -> Result<(), String> { - if path.extension().is_none_or(|extension| extension != "rs") { - return Ok(()); - } - let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - for (index, line) in text.lines().enumerate() { - let trimmed = line.trim_start(); - let field = trimmed - .strip_prefix("pub(crate) ") - .or_else(|| trimmed.strip_prefix("pub ")) - .unwrap_or(trimmed); - if field.starts_with('_') - && !field.starts_with("_ ") - && field.contains(": ") - && !trimmed.starts_with("//") - && !line.contains("PhantomData") - { + for tree in SCANNED_TREES { + for (path, source) in snapshot.rust().under(tree) { + if is_a_judge_fixture(path) { + continue; + } + let file = source.required(path.as_str())?; + for named in suppressed_fields(&file.items) { offenders.push(format!( - "{}:{}: underscore field without PhantomData", - path.display(), - index.saturating_add(1) + "{path}: `{named}` is an underscore field without \ + {TYPE_LEVEL_MARKER}" )); } } - Ok(()) - }; - visit_files(&root.join("src"), &mut inspect)?; - visit_files(&root.join(TOOLING_DIRECTORY), &mut inspect)?; - visit_files(&root.join(JUDGE_DIRECTORY), &mut inspect)?; + } if offenders.is_empty() { Ok(()) } else { @@ -98,73 +147,343 @@ pub(crate) fn check_underscore_fields_are_phantom(root: &Path) -> Result<(), Str } } +/// Whether one source is a fixture the judge feeds to a compiler rather than a +/// source anything builds: a file BENEATH `testpak/tests/` rather than directly +/// in it. +fn is_a_judge_fixture(path: &CanonicalPath) -> bool { + path.is_under(JUDGE_FIXTURES) && !path.sits_directly_in(JUDGE_FIXTURES) +} + +/// Every underscore-prefixed field one item list declares whose type is not the +/// type-level marker, by name. +fn suppressed_fields<'items>(items: impl IntoIterator) -> Vec { + let mut found = Vec::new(); + for item in items { + if let syn::Item::Struct(declared) = item { + collect(&declared.fields, &mut found); + } else if let syn::Item::Union(declared) = item { + for field in &declared.fields.named { + consider(field, &mut found); + } + } else if let syn::Item::Enum(declared) = item { + for variant in &declared.variants { + collect(&variant.fields, &mut found); + } + } else if let syn::Item::Mod(declared) = item + && let Some((_, inner)) = declared.content.as_ref() + { + found.extend(suppressed_fields(inner)); + } else if let syn::Item::Fn(declared) = item { + found.extend(suppressed_fields(nested_items(&declared.block.stmts))); + } else if let syn::Item::Impl(declared) = item { + found.extend(suppressed_fields(method_items(&declared.items))); + } else if let syn::Item::Trait(declared) = item { + found.extend(suppressed_fields(default_items(&declared.items))); + } + } + found +} + +/// The items every method body of one implementation block declares. +/// +/// A record declared inside a method is a record, and its fields are read there +/// exactly as they are read at a module's own level. +fn method_items(members: &[syn::ImplItem]) -> Vec<&syn::Item> { + let mut found = Vec::new(); + for member in members { + if let syn::ImplItem::Fn(road) = member { + found.extend(nested_items(&road.block.stmts)); + } + } + found +} + +/// The items every DEFAULT body of one trait declares. +/// +/// A trait method without a default carries no body and therefore no items, +/// which is a fact about the declaration rather than a member this reader skips. +fn default_items(members: &[syn::TraitItem]) -> Vec<&syn::Item> { + let mut found = Vec::new(); + for member in members { + if let syn::TraitItem::Fn(road) = member + && let Some(body) = road.default.as_ref() + { + found.extend(nested_items(&body.stmts)); + } + } + found +} + +/// The items one function body declares, which is where a record can be +/// declared without standing at a module's own level. +fn nested_items(statements: &[syn::Stmt]) -> impl Iterator { + statements.iter().filter_map(|statement| { + if let syn::Stmt::Item(declared) = statement { + Some(declared) + } else { + None + } + }) +} + +/// Every offending field of one field list. +fn collect(fields: &syn::Fields, into: &mut Vec) { + for field in fields { + consider(field, into); + } +} + +/// One field, kept where it is an underscore field carrying real data. +fn consider(field: &syn::Field, into: &mut Vec) { + let Some(named) = field.ident.as_ref() else { + return; + }; + let spelled = named.to_string(); + if !spelled.starts_with('_') { + return; + } + if !is_the_marker(&field.ty) { + into.push(spelled); + } +} + +/// Whether one declared type IS the type-level marker. +/// +/// The question is asked of the OUTER declaration, because that is what the law +/// claims: an underscore field carries nothing readable. A predicate asking +/// whether the marker appears at any DEPTH answered a different question and +/// admitted `(u64, PhantomData)` — a tuple whose first member is as +/// readable as any field in the tree — on the strength of its second. +/// +/// The path is read for its last segment and for the parameter it takes, so +/// `PhantomData`, `core::marker::PhantomData`, `std::marker::PhantomData` +/// and `marker::PhantomData` are one type written four ways. What the earlier +/// segments SPELL is not judged, because judging it means resolving an import, +/// and resolving is the compiler's question. The one direction that costs is +/// stated where the other ceilings are: a local type named `PhantomData` would +/// be admitted, and separating it from the marker is name resolution. +/// +/// Parentheses and an invisible group are unwrapped because neither is a type — +/// `(T)` is `T`, and a group is a delimiter an expansion left behind. Every +/// other shape refuses, including a reference, a pointer, a tuple, an array, and +/// a slice, each of which is a type that CARRIES the marker rather than being it. +fn is_the_marker(declared: &syn::Type) -> bool { + if let syn::Type::Path(typed) = declared { + return typed.qself.is_none() + && typed.path.segments.last().is_some_and(|last| { + last.ident == TYPE_LEVEL_MARKER + && matches!(last.arguments, syn::PathArguments::AngleBracketed(_)) + }); + } + if let syn::Type::Paren(parenthesized) = declared { + return is_the_marker(&parenthesized.elem); + } + if let syn::Type::Group(grouped) = declared { + return is_the_marker(&grouped.elem); + } + false +} + /// Planted reversals for the laws whose subject is a tree rather than a text. /// -/// A fixture string cannot reach a check that reads a directory, so these are -/// planted against a scratch root outside the repository. Nothing is written -/// inside the repository — the laws that guard the tree are never proven by -/// dirtying the tree. +/// A fixture string cannot reach a law that reads a whole population, so these +/// are planted against a scratch root outside the repository and read through +/// the same snapshot builder a real run uses. Nothing is written inside the +/// repository — the laws that guard the tree are never proven by dirtying the +/// tree. #[cfg(test)] mod tests { - use super::{check_lf_and_no_symlinks, check_no_python, check_underscore_fields_are_phantom}; + use super::{ + SCANNED_TREES, check_lf_and_no_symlinks, check_no_python, + check_underscore_fields_are_phantom, suppressed_fields, + }; use crate::checks::scratch::Scratch; - use std::fs; /// Planted reversal: a file carrying CRLF. /// /// The symlink half of this law is NOT planted. Creating a symlink is a /// privileged operation on one of the supported platforms, so a fixture /// that planted one would pass or fail on who ran it rather than on the - /// law. That half stands on the check's own code and on nothing executed + /// law. That half stands on the law's own code and on nothing executed /// here, and this doc line is where that is admitted rather than implied. #[test] - fn a_crlf_file_is_a_violation() { + fn a_crlf_file_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("lf-only"); scratch.write("clean.md", "one line\nanother\n"); - assert!(check_lf_and_no_symlinks(scratch.root()).is_ok()); + assert!(check_lf_and_no_symlinks(&scratch.read()?).is_ok()); scratch.write("drifted.md", "one line\r\nanother\r\n"); - let found = check_lf_and_no_symlinks(scratch.root()); + let found = check_lf_and_no_symlinks(&scratch.read()?); assert!(found.is_err_and(|reason| reason.contains("CRLF") && reason.contains("drifted"))); + Ok(()) } /// Planted reversal: a Python file anywhere in the tree. #[test] - fn a_python_file_is_a_violation() { + fn a_python_file_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("no-python"); scratch.write("tool.rs", "fn main() {}\n"); scratch.write("notes/readme.md", "prose\n"); - assert!(check_no_python(scratch.root()).is_ok()); + assert!(check_no_python(&scratch.read()?).is_ok()); scratch.write("notes/helper.py", "the file's presence is the offence\n"); - let found = check_no_python(scratch.root()); + let found = check_no_python(&scratch.read()?); assert!(found.is_err_and(|reason| reason.contains("helper.py"))); + Ok(()) } /// Planted reversal: real data behind an underscore — the suppressor idiom /// this law exists to refuse — planted in each of the three trees the scan /// covers, so no tree is scanned in name only. #[test] - fn an_underscore_field_carrying_data_is_a_violation() { + fn an_underscore_field_carrying_data_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("underscore-fields"); - let lawful = "struct Demo {\n _law: PhantomData<*const ()>,\n}\n"; - scratch.write("src/lawful.rs", lawful); - scratch.write("macros/lawful.rs", lawful); - scratch.write("testpak/lawful.rs", lawful); - assert!(check_underscore_fields_are_phantom(scratch.root()).is_ok()); + let lawful = "use core::marker::PhantomData;\n\ + pub struct Demo {\n _law: PhantomData<*const ()>,\n}\n"; + for tree in SCANNED_TREES { + scratch.write(&format!("{tree}/lawful.rs"), lawful); + } + assert!(check_underscore_fields_are_phantom(&scratch.read()?).is_ok()); - for tree in ["src", "macros", "testpak"] { + for tree in SCANNED_TREES { scratch.write( &format!("{tree}/smuggled.rs"), - "struct Demo {\n _hidden: u64,\n}\n", + "pub struct Demo {\n _hidden: u64,\n}\n", ); - let found = check_underscore_fields_are_phantom(scratch.root()); + let found = check_underscore_fields_are_phantom(&scratch.read()?); assert!( found.is_err_and(|reason| reason.contains("smuggled.rs") && reason.contains("underscore field without PhantomData")), "{tree} tree is not scanned" ); - let _removed = fs::remove_file(scratch.root().join(tree).join("smuggled.rs")); + scratch.remove(&format!("{tree}/smuggled.rs")); + } + Ok(()) + } + + /// The reader answers about FIELDS, which the line scanner it replaced + /// could not. + /// + /// Planted reversal in both directions at once. A field declared across two + /// lines is still a field, and the scanner missed it because its subject was + /// a line. A function parameter, a local binding, and a `_field: u64` + /// written inside a doc comment are not fields at all, and the scanner + /// reported every one of them — which is a law refusing lawful sources, the + /// one direction that costs an author a refusal they cannot repair by being + /// right. + #[test] + fn the_reader_answers_about_fields_and_not_about_lines() { + let declared_across_lines = + syn::parse_file("pub struct Demo {\n _hidden:\n u64,\n}\n") + .map(|file| suppressed_fields(&file.items)); + assert!( + declared_across_lines.is_ok_and(|found| found == vec![String::from("_hidden")]), + "a field written across two lines escaped the reader" + ); + + let not_fields = syn::parse_file( + "/// A doc comment showing `_hidden: u64` inside it.\n\ + pub fn road(_ignored: u64) -> u64 {\n\ + \x20 let _unused: u64 = 1;\n\ + \x20 _unused\n\ + }\n\ + pub struct Lawful {\n _law: core::marker::PhantomData<*const ()>,\n}\n", + ) + .map(|file| suppressed_fields(&file.items)); + assert!( + not_fields.is_ok_and(|found| found.is_empty()), + "something that is not a field was reported as one" + ); + } + + /// A record declared inside a function body is still a record, and its + /// fields are still read. + #[test] + fn a_record_declared_inside_a_road_is_still_read() { + let nested = syn::parse_file( + "pub fn road() {\n struct Hidden {\n _smuggled: u64,\n }\n}\n", + ) + .map(|file| suppressed_fields(&file.items)); + assert!( + nested.is_ok_and(|found| found == vec![String::from("_smuggled")]), + "a record declared inside a road escaped the reader" + ); + } + + /// Planted reversal: the same record declared inside a METHOD body, and + /// inside a trait method's DEFAULT body. + /// + /// The walk entered free roads and stopped there, so either of these placed + /// a real underscore field outside a population this law states over the + /// whole repository — and the whole-source scan it replaced had covered + /// both. Each spelling is planted on its own, because each is a separate + /// place the walk did not enter. + #[test] + fn a_record_declared_inside_a_method_is_still_read() { + let bodies = [ + "struct Subject;\nimpl Subject {\n fn road(&self) {\n\ + \x20 struct Hidden { _smuggled: u64 }\n }\n}\n", + "trait Contract {\n fn road(&self) {\n\ + \x20 struct Hidden { _smuggled: u64 }\n }\n}\n", + ]; + for body in bodies { + let found = syn::parse_file(body).map(|file| suppressed_fields(&file.items)); + assert!( + found.is_ok_and(|named| named == vec![String::from("_smuggled")]), + "a record declared inside a method body escaped the reader: {body}" + ); + } + } + + /// Planted reversal: a type that CONTAINS the marker is not the marker. + /// + /// Every spelling below carries something readable beside a `PhantomData`, + /// which flatly contradicts the claim the underscore is admitted under — + /// that the field carries nothing to read. The predicate this replaced asked + /// whether the marker appeared at any depth and passed all six. + #[test] + fn a_type_merely_containing_the_marker_is_not_the_marker() { + for smuggled in [ + "(u64, PhantomData)", + "[PhantomData; 4]", + "&'static PhantomData", + "Option>", + "*const PhantomData", + "Wrapper>", + ] { + let found = syn::parse_file(&format!( + "pub struct Demo {{\n _hidden: {smuggled},\n}}\n" + )) + .map(|file| suppressed_fields(&file.items)); + assert!( + found.is_ok_and(|named| named == vec![String::from("_hidden")]), + "`{smuggled}` passed as the type-level marker" + ); + } + } + + /// The positive control for the same narrowing: the marker itself stays + /// lawful under every path a source reaches it by, and under the parentheses + /// that are a spelling rather than a type. + /// + /// A check that refused everything would satisfy the reversal above and + /// refuse every lawful field in the tree with it. + #[test] + fn the_marker_itself_stays_lawful_under_every_spelling() { + for lawful in [ + "PhantomData", + "core::marker::PhantomData", + "std::marker::PhantomData", + "::core::marker::PhantomData<*const ()>", + "marker::PhantomData", + "(PhantomData)", + ] { + let found = syn::parse_file(&format!("pub struct Demo {{\n _law: {lawful},\n}}\n")) + .map(|file| suppressed_fields(&file.items)); + assert!( + found.is_ok_and(|named| named.is_empty()), + "`{lawful}` was refused, and it is the marker" + ); } } } diff --git a/xtask/src/checks/mint.rs b/xtask/src/checks/mint.rs deleted file mode 100644 index f905823..0000000 --- a/xtask/src/checks/mint.rs +++ /dev/null @@ -1,1392 +0,0 @@ -//! The mint join: a refusal body whose every seat is private is handed back by -//! no road reachable from outside the crate that raises it. -//! -//! A private seat closes the LITERAL. It does not close the ROAD. A function -//! that takes an issue and hands back a refusal is the loading dock behind that -//! fence: any holder of an issue mints a body no pass established, and any -//! holder of a borrowed body clones the issues out and reseats them through the -//! same road. The record either produces is indistinguishable from one a seam -//! returned, which is the whole defect the private seat was supposed to end. -//! -//! The compile-time half of that claim is -//! `testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs`, -//! which names each road and refuses it with `E0624`. This law is the half a -//! fixture cannot state: a fixture names the roads that EXIST, so a family added -//! tomorrow with a public mint leaves it compiling and passing, and the sentence -//! above it goes false with nothing failing. A universal claim needs a universal -//! fixture or a derived population, and an enumeration is neither. Here the -//! population is DERIVED — every closed record the subsystem declares that some -//! road refuses with, and every road that hands one back, read off the sources — -//! so a family added without a crate-internal mint is caught by the derivation -//! rather than by anybody remembering to extend a list. -//! -//! # The two facts, and why each is the one it is -//! -//! **A refusal is what a road refuses with.** The reader takes the error -//! position of every `Result` the subsystem returns. That is the subsystem's own -//! statement about which of its types are refusals, written where the refusing -//! happens, and it needs no marker anybody has to remember to apply. -//! -//! **A body is closed when no seat is public.** A record with a public seat can -//! be spelled as a literal from outside whatever its roads say, so the mint -//! question is not the question about it — the seat is, and -//! `macroc.a-refusal-body-seat-cannot-be-written-from-outside` is where that one -//! is asked. This law is about the records where the seat is already shut. -//! -//! # One question, and it is RESOLVED rather than pattern-matched -//! -//! Does this road hand a caller OWNERSHIP of a closed body? -//! -//! Three readers used to answer that question off the surface of the syntax -//! instead of resolving what the surface stood for, and each of them was the -//! same defect: a road with a receiver was dropped before its output was looked -//! at, an answer was unwrapped through two named wrappers and every other shape -//! recorded the wrapper's own name, and a road in a trait implementation was -//! called reachable because it was in a trait implementation. The first two hid -//! real mints — `Issue::into_refusal(self) -> Body`, `-> Box`, `-> Alias` — -//! and hiding is the dangerous direction, because the check stays green while -//! the obligation is false. The third refused a lawful road under a private -//! trait, which is loud, wrong, and stops a build. -//! -//! So the answer is now walked as a TYPE rather than matched as a spelling: -//! -//! * every position that hands ownership across is followed — a path's generic -//! arguments, a tuple's elements, an array's element, a group's or a -//! parenthesis's inside, an opaque or dynamic type's written bindings, a -//! function pointer's own output, a raw pointer's target — so `Box`, -//! `Vec`, `(Token, Body)` and `impl Iterator` are each a -//! road that hands a body over; -//! * a type ALIAS is resolved to what it stands for, transitively, so a name -//! that is a spelling of the body is the body; -//! * a BORROW is not ownership. `&Body` and `&mut Body` hand out access to a -//! body that already exists, which is what a reader is; nothing new is minted -//! through one, and this is why the seven bodies' own `body()` readers are not -//! roads; -//! * the ERROR position of a `Result` is deliberately not ownership of a new -//! body. That is the caller receiving the refusal a seam raised, which is what -//! the type is FOR, and it is the one exclusion in this walk that is a -//! decision rather than a fact about the shape; -//! * a shape this reader has no reading for — a type a macro produces, a -//! qualified path, a bound it cannot open, a nesting past its stated depth — -//! is an OFFENCE naming the road. Whether such a road hands a body over is -//! unknown, and unknown must not read as no. -//! -//! And a receiver no longer ends the question. A road is a copy rather than a -//! mint only where its RECEIVER IS THE BODY it hands back: that road is handed a -//! body and gives one back, so nothing exists after it that did not exist -//! before. A receiver of any other type is a producer — an issue, a draft, a -//! seam — and it mints. -//! -//! # The population is read from a parse, not from lines -//! -//! The reader is `syn`, for the reason the coupling law's is. What the parse -//! establishes that a scan cannot: that an item IS a record declaration, that a -//! FIELD is public rather than a `pub` somewhere on the line, that a function's -//! declared visibility is `pub` rather than `pub(crate)`, that a return type's -//! error position is the second type argument of a `Result` rather than the -//! second word after a comma, and that a receiver is a receiver. Those are -//! questions about items, members and paths, and answering them off text means -//! writing a Rust parser by hand inside a check. -//! -//! # How far a road reaches, and where that is read from -//! -//! From the declaration that states it, and from nothing else. -//! -//! An INHERENT road states its own visibility, so `pub` is the reading. That is -//! exact whenever the type is reachable — an inherent implementation is not -//! name-resolved through modules, so a `pub` road on a public type is reachable -//! however private the module that writes it — and strict otherwise. -//! -//! A road in a TRAIT implementation states no visibility of its own: it is -//! exactly as reachable as the trait it implements. So the trait is what this -//! reader resolves. A contract the subsystem declares is read at its own -//! declared visibility, which is why a `trait` or a `pub(crate) trait` closes -//! every road under it; a contract the subsystem does not declare is foreign — -//! `From`, `Default`, band 00's own contracts — and a downstream crate can name -//! it, so it is reachable. -//! -//! **The stated ceiling: a module chain is not consulted, and that is a -//! direction rather than an oversight.** A `pub` item inside a module nobody -//! re-exports is not in fact reachable, so reading `pub` as reachable can refuse -//! a road that no outside caller could ever spell. That is the loud direction, -//! and the repair is one word on the declaration. Consulting the chain could -//! only move verdicts the other way — toward "unreachable", toward passing — -//! and this subsystem publishes almost everything through `pub use` -//! re-exports out of private modules, so a chain-walking reader that missed one -//! re-export would call a genuinely reachable mint closed and say nothing at -//! all. Between a refusal somebody can argue with and a silence nobody can see, -//! this reader takes the refusal. -//! -//! # What this reader does not resolve -//! -//! Its subject is the metaprogramming subsystem and not the machine. Band 00's -//! own report package carries private seats and a PUBLIC mint on purpose — that -//! mint is the road the services reach for — so a law reading the machine on -//! these terms would refuse the thing it depends on. The machine's bodies are -//! guarded by their own laws and this one says nothing about them. -//! -//! A record is resolved by its declared NAME across the whole subsystem rather -//! than inside a home. That is the strict direction and it is deliberate: a road -//! anywhere in the subsystem that hands back a body is a road, wherever the body -//! was declared, and scoping the join to a home would leave exactly the -//! cross-home mint this law exists to refuse outside it. Two homes declaring one -//! name are read as one subject, so a road for either is judged against both — -//! the offence names both paths, and the repair is a rename rather than a looser -//! reading. -//! -//! It does not compile anything. A path is read by its LAST SEGMENT, so -//! `ProjectionPlanning` and `refusal::ProjectionPlanning` are one name here. It -//! does not evaluate `cfg`: a member written under one is read as declared. It -//! does not expand macros, so a record or a road assembled by one is outside -//! this law — and a return type a macro produces is refused rather than passed -//! over, because that one this reader can see. - -use std::fs; -use std::path::Path; - -use crate::repository::walk::{TOOLING_DIRECTORY, relative_slash_path, visit_files}; - -/// The proof surface, excluded from the population by name. -/// -/// The services' `laws.rs` declares demonstration families whose whole content -/// is a pair of constants — they exist so the admission algebra has something to -/// refuse, and counting them would put a fixture in a denominator about the -/// subsystem. Excluded for the reason and by the name the coupling law excludes -/// it. -const PROOF_SURFACE: &str = "macros/macroc/src/laws.rs"; - -/// The return a road refuses through, whose SECOND type argument is the refusal -/// rather than the answer. -const REFUSING_RETURN: &str = "Result"; - -/// The spelling a road inside an implementation names its own type by. -const OWN_TYPE: &str = "Self"; - -/// How deep this reader follows a return type through wrappers, aliases, -/// generic arguments and bindings. -/// -/// Stated rather than unbounded, and a return type nested past it is an offence -/// rather than a shrug: the depth is what makes the ceiling a number somebody -/// can raise instead of a silence nobody can see. -const RESOLUTION_DEPTH: usize = 16; - -/// Every refusal body the metaprogramming subsystem declares with every seat -/// private is handed back by no road reachable from outside the crate. -/// -/// # Errors -/// -/// Returns the offences one line at a time, and returns a read failure as -/// itself: a gate that cannot read its subject says so rather than reporting an -/// empty population. -pub(crate) fn check_refusal_mints_are_inside_the_plane(root: &Path) -> Result<(), String> { - let sources = services_sources(root)?; - let verdict = mint_verdict(&sources); - - // The denominators are DERIVED and printed on every run, because a - // population that quietly shrank would otherwise keep this check passing - // while it guarded less. - println!( - "refusal mints: {} roads / {} closed refusal bodies", - verdict.roads, verdict.bodies - ); - if verdict.bodies == 0 { - return Err(String::from( - "no closed refusal body was found in the metaprogramming subsystem: this denominator \ - cannot be empty while the services refuse, so the reader is looking at the wrong tree", - )); - } - if verdict.offenders.is_empty() { - Ok(()) - } else { - Err(verdict.offenders.join("; ")) - } -} - -/// What the mint leg counted, and what it refuses. -#[derive(Debug)] -struct MintVerdict { - /// Closed records some road refuses with: the refusal bodies. - bodies: usize, - /// Roads that hand one of those bodies over. - roads: usize, - /// Roads reachable from outside the crate, bodies no road produces, shapes - /// the reader could not resolve, and sources it could not parse — one - /// offence each. - offenders: Vec, -} - -/// One record the subsystem declares whose every seat is private. -struct ClosedRecord { - /// The repository-relative path that declares it. - path: String, - /// Its declared name. - name: String, -} - -/// One road that hands ownership of something across. -struct Road { - /// The repository-relative path that declares it. - path: String, - /// Its declared name. - name: String, - /// Every name it hands a caller ownership of, by last path segment, with - /// `Self` resolved to the type the enclosing implementation is for and - /// aliases resolved to what they stand for. - owned: Vec, - /// The type of its receiver, where it has one. A road whose receiver IS the - /// body it hands back is a copy of a body that already existed; a road with - /// a receiver of any other type, or none, mints. - receiver: Option, - /// How far it reaches. - reach: Reach, -} - -/// How far one road reaches — a named pair rather than a flag, because "true" -/// at a call site says nothing about which direction it means. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Reach { - /// Out of the crate: the declaration that states this road's visibility - /// says `pub`, or the contract it implements is one this subsystem does not - /// declare and a downstream crate can name. - OutsideTheCrate, - /// No further than the crate: `pub(crate)`, `pub(in …)`, `pub(super)`, the - /// absence of any spelling at all, or a contract declared here at any of - /// those. - InsideTheCrate, -} - -/// Which side of a road's answer one position stands on. -#[derive(Debug, Clone, Copy)] -enum Slot { - /// What the road hands back as its answer. - Answer, - /// What the road refuses with. - Refusal, -} - -/// Whether the arguments being read belong to the refusing return, whose SECOND -/// type argument stands on the other side from the first. -#[derive(Debug, Clone, Copy)] -enum Refusing { - /// They do. - Yes, - /// They do not, so every argument stands where its parent stood. - No, -} - -/// Everything one pass over the parsed trees establishes before any road is -/// judged. -/// -/// Read first and whole, because a road in one file is resolved against an alias -/// or a contract declared in another: a reader that answered as it walked would -/// answer differently depending on which file it reached first. -struct Declarations<'a> { - /// Every record declared with no public seat. - closed: Vec, - /// Every type alias the subsystem declares: the name, and the type it - /// stands for. - aliases: Vec<(String, &'a syn::Type)>, - /// Every contract the subsystem declares, and how far its own declaration - /// reaches. - contracts: Vec<(String, Reach)>, -} - -/// Everything one pass over the roads read. -struct Reading { - /// Every type name a road in the subsystem refuses with. - refused: Vec, - /// Every road that hands ownership across. - roads: Vec, - /// Return shapes this reader has no reading for, one offence each. Never a - /// skip: whether such a road hands a body over is unknown rather than false. - unresolvable: Vec, -} - -/// Reads the records, the refusals and the roads out of source text and judges -/// each body. -/// -/// Pure over its inputs — `(repository-relative path, source text)` pairs — so -/// the reversals below are planted in memory and the law that guards the tree is -/// never proven by opening a seat in one. -fn mint_verdict(sources: &[(String, String)]) -> MintVerdict { - let mut offenders = Vec::new(); - let mut parsed = Vec::new(); - for (path, text) in sources { - match syn::parse_file(text) { - Ok(file) => parsed.push((path.clone(), file)), - Err(error) => offenders.push(format!( - "{path}: this file is not parseable Rust, so the population derived from it is \ - unknown rather than empty: {error}" - )), - } - } - let declarations = read_declarations(&parsed); - let Reading { - refused, - roads, - unresolvable, - } = read_roads(&parsed, &declarations); - offenders.extend(unresolvable); - let mut verdict = MintVerdict { - bodies: 0, - roads: 0, - offenders, - }; - judge(&declarations, &refused, &roads, &mut verdict); - verdict -} - -/// Judges every closed record some road refuses with. -fn judge( - declarations: &Declarations<'_>, - refused_with: &[String], - declared_roads: &[Road], - verdict: &mut MintVerdict, -) { - for record in &declarations.closed { - let name = &record.name; - if !refused_with.iter().any(|refused| refused == name) { - continue; - } - verdict.bodies = verdict.bodies.saturating_add(1); - let declared = &record.path; - let roads: Vec<&Road> = declared_roads - .iter() - .filter(|road| road.mints(name)) - .collect(); - if roads.is_empty() { - verdict.offenders.push(format!( - "{declared}: {name} is refused with and every seat it declares is private, and no \ - road in the subsystem hands one back; this law's numerator over it is empty, \ - which guards nothing while reading as coverage" - )); - continue; - } - for road in roads { - verdict.roads = verdict.roads.saturating_add(1); - if road.reach == Reach::OutsideTheCrate { - let at = &road.path; - let spelled = &road.name; - verdict.offenders.push(format!( - "{at}: {name}::{spelled} hands back {name}, whose every seat {declared} \ - declares is private, and it is reachable from outside the crate; a private \ - seat closes the literal and a public road is the loading dock behind it, \ - because any holder of an issue mints a body no pass established" - )); - } - } - } -} - -impl Road { - /// Whether this road MINTS the named body rather than copying one. - /// - /// It hands the body's ownership across, and its receiver is not that same - /// body. A road whose receiver IS the body is handed one and gives one back, - /// so nothing exists after it that did not exist before; a road with a - /// receiver of any other type is holding the parts rather than the whole, - /// which is exactly the loading dock this law is about. - fn mints(&self, body: &str) -> bool { - self.owned.iter().any(|owned| owned == body) && self.receiver.as_deref() != Some(body) - } -} - -/// Every declaration the subsystem makes that a road is resolved against. -fn read_declarations(parsed: &[(String, syn::File)]) -> Declarations<'_> { - let mut declarations = Declarations { - closed: Vec::new(), - aliases: Vec::new(), - contracts: Vec::new(), - }; - for (path, file) in parsed { - read_declared_items(path, &file.items, &mut declarations); - } - declarations -} - -/// Reads one module's declarations, then every inline module inside it. -/// -/// Written as an `if let` chain rather than a match because `syn::Item` is -/// `non_exhaustive`: the items this reading has a question about are named, and -/// every other item is passed over without a wildcard arm standing in for a set -/// no crate outside `syn` can enumerate. -fn read_declared_items<'a>( - path: &str, - items: &'a [syn::Item], - declarations: &mut Declarations<'a>, -) { - for item in items { - if let syn::Item::Struct(declared) = item { - if is_closed(declared) { - declarations.closed.push(ClosedRecord { - path: path.to_string(), - name: declared.ident.to_string(), - }); - } - } else if let syn::Item::Type(declared) = item { - declarations - .aliases - .push((declared.ident.to_string(), &declared.ty)); - } else if let syn::Item::Trait(declared) = item { - declarations - .contracts - .push((declared.ident.to_string(), reach_of(&declared.vis))); - } else if let syn::Item::Mod(module) = item - && let Some((_, inner)) = &module.content - { - read_declared_items(path, inner, declarations); - } - } -} - -/// Every road the subsystem declares, judged against what it declares. -fn read_roads<'a>(parsed: &'a [(String, syn::File)], declarations: &Declarations<'a>) -> Reading { - let mut reading = Reading { - refused: Vec::new(), - roads: Vec::new(), - unresolvable: Vec::new(), - }; - for (path, file) in parsed { - read_module(path, &file.items, declarations, &mut reading); - } - reading -} - -/// Reads one module's roads, then every inline module inside it. -fn read_module<'a>( - path: &str, - items: &'a [syn::Item], - declarations: &Declarations<'a>, - reading: &mut Reading, -) { - for item in items { - if let syn::Item::Fn(declared) = item { - let road = Standing { - reach: reach_of(&declared.vis), - own: None, - }; - read_signature(path, &declared.sig, &road, declarations, reading); - } else if let syn::Item::Impl(declared) = item { - read_implementation(path, declared, declarations, reading); - } else if let syn::Item::Trait(declared) = item { - read_contract(path, declared, declarations, reading); - } else if let syn::Item::Mod(module) = item - && let Some((_, inner)) = &module.content - { - read_module(path, inner, declarations, reading); - } - } -} - -/// Where one road stands: how far it reaches, and what `Self` means inside it. -struct Standing { - /// How far the declaration that states this road's visibility reaches. - reach: Reach, - /// The type the enclosing implementation is for, where there is one. - own: Option, -} - -/// Reads every road one implementation declares. -/// -/// A road in a trait implementation states no visibility of its own, so the -/// trait it implements is what decides how far it reaches — resolved against -/// what the subsystem declares rather than assumed from the fact that a trait is -/// there at all. That assumption refused a lawful road under a private trait, -/// which is why the resolution is here. -fn read_implementation<'a>( - path: &str, - declared: &'a syn::ItemImpl, - declarations: &Declarations<'a>, - reading: &mut Reading, -) { - let own = head_of(&declared.self_ty); - let contract = declared - .trait_ - .as_ref() - .and_then(|(named, _)| last_segment(named)); - for member in &declared.items { - if let syn::ImplItem::Fn(road) = member { - let reach = match contract.as_deref() { - Some(named) => contract_reach(named, declarations), - None => reach_of(&road.vis), - }; - let standing = Standing { - reach, - own: own.clone(), - }; - read_signature(path, &road.sig, &standing, declarations, reading); - } - } -} - -/// Reads every road one contract declares. -/// -/// A contract's road is as reachable as the contract, and the contract states -/// that itself. -fn read_contract<'a>( - path: &str, - declared: &'a syn::ItemTrait, - declarations: &Declarations<'a>, - reading: &mut Reading, -) { - let standing = Standing { - reach: reach_of(&declared.vis), - own: None, - }; - for member in &declared.items { - if let syn::TraitItem::Fn(road) = member { - read_signature(path, &road.sig, &standing, declarations, reading); - } - } -} - -/// How far a road under one named contract reaches. -/// -/// A contract this subsystem DECLARES answers at its own declared visibility, so -/// a `trait` or a `pub(crate) trait` closes every road under it. A contract it -/// does not declare is foreign — `From`, `Default`, band 00's own contracts — -/// and a downstream crate can name it, so a road under one is reachable. -fn contract_reach(named: &str, declarations: &Declarations<'_>) -> Reach { - declarations - .contracts - .iter() - .find(|(declared, _)| declared == named) - .map_or(Reach::OutsideTheCrate, |&(_, reach)| reach) -} - -/// How far one declared visibility reaches. -/// -/// `pub` and nothing else. `pub(crate)`, `pub(in …)` and `pub(super)` arrive as -/// `syn::Visibility::Restricted` and reach no further than the crate, which is -/// exactly the distinction this law turns on. -fn reach_of(declared: &syn::Visibility) -> Reach { - if matches!(*declared, syn::Visibility::Public(_)) { - Reach::OutsideTheCrate - } else { - Reach::InsideTheCrate - } -} - -/// Reads both legs off one signature: what it refuses with, and what it hands a -/// caller ownership of. -fn read_signature<'a>( - path: &str, - sig: &'a syn::Signature, - standing: &Standing, - declarations: &Declarations<'a>, - reading: &mut Reading, -) { - let mut walk = Walk { - aliases: &declarations.aliases, - own: standing.own.clone(), - owned: Vec::new(), - refused: Vec::new(), - unresolvable: Vec::new(), - }; - walk.read_output(&sig.output, Slot::Answer, RESOLUTION_DEPTH); - let name = sig.ident.to_string(); - reading.refused.extend(walk.refused); - for unreadable in walk.unresolvable { - reading.unresolvable.push(format!( - "{path}: `{name}` returns {unreadable}, so whether it hands a caller a refusal body is \ - unknown rather than false; a shape this reader cannot resolve is refused rather than \ - passed over" - )); - } - if !walk.owned.is_empty() { - reading.roads.push(Road { - path: path.to_string(), - name, - owned: walk.owned, - receiver: receiver_of(sig, standing.own.as_deref()), - reach: standing.reach, - }); - } -} - -/// The type of one signature's receiver, where it has one. -/// -/// A receiver is the enclosing implementation's own type, which is what makes -/// "is the receiver the body it hands back" an answerable question rather than a -/// flag. -fn receiver_of(sig: &syn::Signature, own: Option<&str>) -> Option { - let takes_one = sig - .inputs - .iter() - .any(|input| matches!(*input, syn::FnArg::Receiver(_))); - if takes_one { - own.map(str::to_string) - } else { - None - } -} - -/// One road's return type, walked. -/// -/// Everything it hands ownership of, everything it refuses with, and every shape -/// it could not resolve — gathered in one pass over one type, so no two readings -/// of the same signature can disagree. -struct Walk<'d, 'a> { - /// The aliases the subsystem declares, for resolving a name to what it - /// stands for. - aliases: &'d [(String, &'a syn::Type)], - /// The type the enclosing implementation is for, which is what `Self` means - /// inside it. - own: Option, - /// Every name the road hands a caller ownership of. - owned: Vec, - /// Every name the road refuses with. - refused: Vec, - /// Every shape this reader has no reading for, described. - unresolvable: Vec, -} - -impl<'a> Walk<'_, 'a> { - /// Reads one return type, or nothing where the road returns nothing. - fn read_output(&mut self, output: &'a syn::ReturnType, slot: Slot, depth: usize) { - if let syn::ReturnType::Type(_, declared) = output { - self.read(declared, slot, depth); - } - } - - /// Reads one type: every position that hands ownership across, followed. - /// - /// A borrow stops the walk — access to a body that already exists is what a - /// reader is, and nothing new comes through one. Everything this reader has - /// no reading for lands in `unresolvable`, which is the whole difference - /// between a ceiling and a hole. - fn read(&mut self, declared: &'a syn::Type, slot: Slot, depth: usize) { - let Some(inside) = depth.checked_sub(1) else { - self.unresolvable.push(format!( - "a type nested deeper than the {RESOLUTION_DEPTH} levels this reader follows" - )); - return; - }; - if let syn::Type::Path(typed) = declared { - self.read_path(typed, slot, inside); - } else if let syn::Type::Tuple(tuple) = declared { - for element in &tuple.elems { - self.read(element, slot, inside); - } - } else if let syn::Type::Array(array) = declared { - self.read(&array.elem, slot, inside); - } else if let syn::Type::Slice(slice) = declared { - self.read(&slice.elem, slot, inside); - } else if let syn::Type::Group(group) = declared { - self.read(&group.elem, slot, inside); - } else if let syn::Type::Paren(paren) = declared { - self.read(&paren.elem, slot, inside); - } else if let syn::Type::Ptr(pointer) = declared { - self.read(&pointer.elem, slot, inside); - } else if let syn::Type::ImplTrait(opaque) = declared { - self.read_bounds(&opaque.bounds, slot, inside); - } else if let syn::Type::TraitObject(dynamic) = declared { - self.read_bounds(&dynamic.bounds, slot, inside); - } else if let syn::Type::FnPtr(pointer) = declared { - self.read_output(&pointer.output, slot, inside); - } else if !matches!( - *declared, - syn::Type::Reference(_) | syn::Type::Never(_) | syn::Type::Infer(_) - ) { - self.unresolvable - .push(String::from("a type shape this reader has no reading for")); - } - } - - /// Reads one path type: the name it stands for, and everything its - /// arguments hand across. - fn read_path(&mut self, typed: &'a syn::TypePath, slot: Slot, depth: usize) { - if typed.qself.is_some() { - self.unresolvable.push(String::from( - "a qualified path, whose meaning is decided by an implementation this reader does \ - not resolve", - )); - return; - } - let Some(head) = last_segment(&typed.path) else { - self.unresolvable - .push(String::from("a path with no segment at all")); - return; - }; - let refusing = if head == REFUSING_RETURN { - Refusing::Yes - } else { - Refusing::No - }; - // `Self` inside an implementation is the type that implementation is - // for, and a name the subsystem declares an alias for is the type that - // alias stands for. Neither is a different type from the one it spells. - let named = if head == OWN_TYPE { - self.own.clone().unwrap_or(head) - } else { - head - }; - let stands_for = self - .aliases - .iter() - .find(|(declared, _)| *declared == named) - .map(|&(_, target)| target); - if let Some(target) = stands_for { - self.read(target, slot, depth); - return; - } - match slot { - Slot::Answer => self.owned.push(named), - Slot::Refusal => self.refused.push(named), - } - if let Some(last) = typed.path.segments.last() { - self.read_arguments(&last.arguments, slot, refusing, depth); - } - } - - /// Reads one path segment's arguments. - /// - /// The refusing return's SECOND type argument is the one position in this - /// walk that changes sides, and it changes sides by decision: a seam handing - /// a caller the refusal it raised is what the type is for. - fn read_arguments( - &mut self, - arguments: &'a syn::PathArguments, - slot: Slot, - refusing: Refusing, - depth: usize, - ) { - if let syn::PathArguments::AngleBracketed(bracketed) = arguments { - self.read_angle_bracketed(&bracketed.args, slot, refusing, depth); - } else if let syn::PathArguments::Parenthesized(spelled) = arguments { - self.read_output(&spelled.output, slot, depth); - } - } - - /// Reads one angle-bracketed argument list, counting TYPE positions only. - /// - /// The position is what the refusing return's second slot is identified by, - /// and a lifetime standing ahead of a type does not move it: `Result<'a, T, - /// E>` is not a thing, but a reader that counted every argument would be one - /// lifetime away from calling the answer a refusal. - fn read_angle_bracketed( - &mut self, - arguments: &'a syn::punctuated::Punctuated, - slot: Slot, - refusing: Refusing, - depth: usize, - ) { - let mut position = 0usize; - for argument in arguments { - self.read_argument(argument, slot, refusing, position, depth); - if matches!(*argument, syn::GenericArgument::Type(_)) { - position = position.saturating_add(1); - } - } - } - - /// Reads one generic argument, standing where its position puts it. - fn read_argument( - &mut self, - argument: &'a syn::GenericArgument, - slot: Slot, - refusing: Refusing, - position: usize, - depth: usize, - ) { - if let syn::GenericArgument::Type(inner) = argument { - let stands = match (refusing, position) { - (Refusing::Yes, 1) => Slot::Refusal, - (Refusing::Yes | Refusing::No, _) => slot, - }; - self.read(inner, stands, depth); - } else if let syn::GenericArgument::AssocType(bound) = argument { - self.read(&bound.ty, slot, depth); - } else if let syn::GenericArgument::Constraint(constrained) = argument { - self.read_bounds(&constrained.bounds, slot, depth); - } else if !matches!( - *argument, - syn::GenericArgument::Lifetime(_) - | syn::GenericArgument::Const(_) - | syn::GenericArgument::AssocConst(_) - ) { - self.unresolvable.push(String::from( - "a generic argument this reader has no reading for", - )); - } - } - - /// Reads the bounds of an opaque or dynamic type: what a caller holding one - /// can get out of it is whatever its written bindings say. - fn read_bounds( - &mut self, - bounds: &'a syn::punctuated::Punctuated, - slot: Slot, - depth: usize, - ) { - for bound in bounds { - self.read_bound(bound, slot, depth); - } - } - - /// Reads one bound: a contract's written bindings are what a caller holding - /// the opaque value can get out of it. - fn read_bound(&mut self, bound: &'a syn::TypeParamBound, slot: Slot, depth: usize) { - if let syn::TypeParamBound::Trait(contract) = bound { - if let Some(last) = contract.path.segments.last() { - self.read_arguments(&last.arguments, slot, Refusing::No, depth); - } - } else if !matches!( - *bound, - syn::TypeParamBound::Lifetime(_) | syn::TypeParamBound::PreciseCapture(_) - ) { - self.unresolvable.push(String::from( - "a bound this reader cannot open, so what the opaque type yields is unknown", - )); - } - } -} - -/// One record declared with at least one seat and no public seat. -/// -/// A record with no seat at all is not a body: there is nothing a literal could -/// write and nothing a mint could fill, so the question this law asks is not -/// about it. -fn is_closed(declared: &syn::ItemStruct) -> bool { - if !matches!(declared.vis, syn::Visibility::Public(_)) { - return false; - } - let mut seats = declared.fields.iter(); - let Some(first) = seats.next() else { - return false; - }; - [first] - .into_iter() - .chain(seats) - .all(|seat| !matches!(seat.vis, syn::Visibility::Public(_))) -} - -/// The last path segment of one type, or `None` where the type is not a plain -/// path. -/// -/// Generic arguments are not part of the head, so `ProjectionClosureRefusal` -/// and `ProjectionClosureRefusal` name one type here, exactly as they name one -/// body. -fn head_of(declared: &syn::Type) -> Option { - if let syn::Type::Path(typed) = declared { - last_segment(&typed.path) - } else { - None - } -} - -/// The last segment of one path, by name. -fn last_segment(path: &syn::Path) -> Option { - path.segments.last().map(|last| last.ident.to_string()) -} - -/// Every source file the population is derived from: the metaprogramming -/// subsystem's own sources, minus the proof surface. -fn services_sources(root: &Path) -> Result, String> { - let base = root.join(TOOLING_DIRECTORY); - if !base.is_dir() { - return Err(format!( - "{TOOLING_DIRECTORY}/ is not there: the subsystem this law is about cannot be read, \ - which is not the same as its having no refusal bodies" - )); - } - let mut sources = Vec::new(); - visit_files(&base, &mut |path| { - if path.extension().is_none_or(|extension| extension != "rs") { - return Ok(()); - } - let relative = relative_slash_path(root, path); - if relative == PROOF_SURFACE { - return Ok(()); - } - let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - sources.push((relative, text)); - Ok(()) - })?; - Ok(sources) -} - -/// Planted reversals for the join, and the real subsystem judged by it. -/// -/// The leg is pure over `(path, text)` pairs, so a reversal is a fixture held in -/// memory: the law that guards the services' mints is never proven by opening -/// one. The test that reads the real tree is named `the_real_…` and states what -/// it found rather than what it hoped for. -#[cfg(test)] -mod tests { - use super::{mint_verdict, services_sources}; - use crate::repository::walk::repo_root; - - /// The seam that puts a name into the refused population, which is what - /// makes a closed record a refusal body. - const SEAM: &str = "impl DemoSeam {\n\ - \x20 pub fn checked(&self) -> Result {\n\ - \x20 Ok(Self)\n\ - \x20 }\n\ - }\n"; - - /// The closed record every fixture below is about. - const BODY: &str = "pub struct DemoRefusal {\n\ - \x20 body: AdmittedPrefix,\n\ - }\n"; - - /// One synthetic source file: the body, the seam that refuses with it, and - /// whatever roads the case is about. - fn source(roads: &str) -> Vec<(String, String)> { - vec![( - String::from("macros/macroc/src/home/types.rs"), - format!("{BODY}\n{roads}\n{SEAM}"), - )] - } - - /// One synthetic source file at a named path. - fn source_at(path: &str, text: &str) -> (String, String) { - (path.to_string(), text.to_string()) - } - - /// The positive control: a closed body, refused with, whose one road is - /// crate-internal. A check that flagged everything would satisfy every - /// reversal below and be worthless. - #[test] - fn a_crate_internal_mint_is_lawful() { - let verdict = source( - "impl DemoRefusal {\n\ - \x20 pub(crate) fn established(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - ); - let verdict = mint_verdict(&verdict); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// Planted reversal: the fence with a loading dock behind it. The seat is - /// private, the record cannot be written as a literal from outside, and one - /// `pub` road hands the whole body back to any holder of an issue. - #[test] - fn a_public_mint_on_a_closed_body_is_a_violation() { - let verdict = mint_verdict(&source( - "impl DemoRefusal {\n\ - \x20 pub fn established(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("DemoRefusal::established")) - ); - } - - /// Planted reversal for the receiver blind spot: a road on ANOTHER type that - /// consumes an issue and hands back the body. A reader that dropped every - /// road with a receiver before looking at its output let this through in - /// silence while the crate-internal constructors kept the numerator full. - #[test] - fn a_receiver_of_another_type_still_mints() { - let verdict = mint_verdict(&source( - "impl DemoIssue {\n\ - \x20 pub fn into_refusal(self) -> DemoRefusal {\n\ - \x20 DemoRefusal { body: AdmittedPrefix::carrying_one(self) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("DemoRefusal::into_refusal")) - ); - } - - /// A road whose receiver IS the body is a copy of one that already existed, - /// and it is not a mint. Reading it as one would refuse a hand-written - /// `Clone` and admit the derived one, which is a verdict decided by how a - /// road was spelled. - #[test] - fn a_receiver_of_the_body_is_a_copy_road() { - let verdict = mint_verdict(&source( - "impl DemoRefusal {\n\ - \x20 pub fn duplicated(&self) -> Self {\n\ - \x20 Self { body: self.body.clone() }\n\ - \x20 }\n\ - \x20 pub(crate) fn established(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// Planted reversal for the wrapper blind spot: the body handed back inside - /// a `Box`. A reader unwrapping two named wrappers recorded `Box` and - /// counted no mint at all — the same evasion this repository has already - /// repaired once, in another law, through the same wrapper. - #[test] - fn a_boxed_answer_still_mints() { - let verdict = mint_verdict(&source( - "pub fn boxed(issue: DemoIssue) -> Box {\n\ - \x20 Box::new(DemoRefusal { body: AdmittedPrefix::carrying_one(issue) })\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("DemoRefusal::boxed")) - ); - } - - /// Planted reversal for the alias blind spot: the body handed back under a - /// name that stands for it. A name is not a different type. - #[test] - fn an_alias_for_the_body_still_mints() { - let verdict = mint_verdict(&source( - "pub type DemoOutcome = DemoRefusal;\n\ - \n\ - pub fn aliased(issue: DemoIssue) -> DemoOutcome {\n\ - \x20 DemoRefusal { body: AdmittedPrefix::carrying_one(issue) }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("DemoRefusal::aliased")) - ); - } - - /// The same evasion through every other ownership position: a tuple, a - /// collection, and an opaque iterator each hand a body across. - #[test] - fn every_ownership_position_still_mints() { - for spelled in [ - "pub fn paired(issue: DemoIssue) -> (DemoToken, DemoRefusal) { todo() }\n", - "pub fn collected(issue: DemoIssue) -> Vec { todo() }\n", - "pub fn streamed(issue: DemoIssue) -> impl Iterator { todo() }\n", - "pub fn optional(issue: DemoIssue) -> Option { todo() }\n", - ] { - let verdict = mint_verdict(&source(spelled)); - assert_eq!(verdict.bodies, 1, "{spelled}"); - assert_eq!(verdict.roads, 1, "{spelled}"); - assert_eq!( - verdict.offenders.len(), - 1, - "{spelled}: {:?}", - verdict.offenders - ); - } - } - - /// A borrow is not ownership. The seven bodies' own readers hand back - /// `&AdmittedPrefix<…>` and an iterator of borrows, and a reader that - /// counted those would refuse every public reader in the subsystem. - #[test] - fn a_borrowed_answer_is_not_a_mint() { - let verdict = mint_verdict(&source( - "impl DemoRefusal {\n\ - \x20 pub const fn body(&self) -> &AdmittedPrefix {\n\ - \x20 &self.body\n\ - \x20 }\n\ - }\n\ - \n\ - pub fn borrowed(held: &DemoRefusal) -> &DemoRefusal {\n\ - \x20 held\n\ - }\n\ - \n\ - impl DemoRefusal {\n\ - \x20 pub(crate) fn established(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// A public seam that REFUSES with the body is not a mint. This is the - /// distinction the whole leg turns on: a caller receiving the refusal a seam - /// raised is what the type exists for, and a reader that could not tell the - /// error position from the success one would refuse every refusing road in - /// the subsystem. - #[test] - fn a_public_refusing_seam_is_not_a_mint() { - let verdict = mint_verdict(&source( - "impl DemoRefusal {\n\ - \x20 pub(crate) fn established(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.roads, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// Planted reversal for the trait-implementation flag, in the direction it - /// failed: a PRIVATE contract, implemented for a producer, whose road hands - /// a closed body back. No caller outside the crate can name the contract, so - /// no caller outside the crate can reach the road — and a reader that called - /// every trait road reachable refused this one, which is a build stopped - /// over a road that does not exist from outside. - #[test] - fn a_road_under_a_private_contract_is_not_reachable() { - let verdict = mint_verdict(&source( - "trait DemoRaises {\n\ - \x20 fn raised(&self) -> DemoRefusal;\n\ - }\n\ - \n\ - impl DemoRaises for DemoIssue {\n\ - \x20 fn raised(&self) -> DemoRefusal {\n\ - \x20 DemoRefusal { body: AdmittedPrefix::carrying_one(*self) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 2); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// The other direction of the same resolution: a PUBLIC contract is one a - /// downstream crate can name, so the road under it is reachable and the - /// mint is refused. A reader that answered "not reachable" for every trait - /// road would have closed the false refusal by opening a silence. - #[test] - fn a_road_under_a_public_contract_is_reachable() { - let verdict = mint_verdict(&source( - "pub trait DemoRaises {\n\ - \x20 fn raised(&self) -> DemoRefusal;\n\ - }\n\ - \n\ - impl DemoRaises for DemoIssue {\n\ - \x20 fn raised(&self) -> DemoRefusal {\n\ - \x20 DemoRefusal { body: AdmittedPrefix::carrying_one(*self) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 2); - assert_eq!(verdict.offenders.len(), 2, "{:?}", verdict.offenders); - } - - /// A contract this subsystem does not declare is one a downstream crate - /// already has: `impl From for DemoRefusal` is a public mint - /// spelled as a conversion, and it is refused as one. - #[test] - fn a_road_under_a_foreign_contract_is_reachable() { - let verdict = mint_verdict(&source( - "impl From for DemoRefusal {\n\ - \x20 fn from(issue: DemoIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 1); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("DemoRefusal::from")) - ); - } - - /// A body added later with a public mint is refused by the same derivation, - /// with nothing about it written down anywhere: the population is derived - /// rather than named, which is the whole of what this law adds to the - /// fixture beside it. - #[test] - fn a_family_added_later_is_already_in_the_population() { - let lawful = format!( - "{BODY}\nimpl DemoRefusal {{\n\ - \x20 pub(crate) fn established(issue: DemoIssue) -> Self {{\n\ - \x20 Self {{ body: AdmittedPrefix::carrying_one(issue) }}\n\ - \x20 }}\n\ - }}\n\n{SEAM}" - ); - let verdict = mint_verdict(&[ - source_at("macros/macroc/src/home/types.rs", &lawful), - source_at( - "macros/macroc/src/later/types.rs", - "pub struct LaterRefusal {\n\ - \x20 body: AdmittedPrefix,\n\ - }\n\ - \n\ - impl LaterRefusal {\n\ - \x20 pub fn established(issue: LaterIssue) -> Self {\n\ - \x20 Self { body: AdmittedPrefix::carrying_one(issue) }\n\ - \x20 }\n\ - }\n\ - \n\ - impl LaterSeam {\n\ - \x20 pub fn checked(&self) -> Result {\n\ - \x20 Ok(Self)\n\ - \x20 }\n\ - }\n", - ), - ]); - assert_eq!(verdict.bodies, 2); - assert_eq!(verdict.roads, 2); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("LaterRefusal::established")) - ); - } - - /// A record with a public seat is not this law's subject, whatever its roads - /// say. The literal is already writable from outside, so the question about - /// it is the SEAT's, and answering it here would report the wrong defect. - #[test] - fn a_record_with_a_public_seat_is_not_in_the_population() { - let verdict = mint_verdict(&[source_at( - "macros/macroc/src/home/types.rs", - &format!( - "pub struct DemoRefusal {{\n\ - \x20 pub body: AdmittedPrefix,\n\ - }}\n\n\ - impl DemoRefusal {{\n\ - \x20 pub fn established(issue: DemoIssue) -> Self {{\n\ - \x20 Self {{ body: AdmittedPrefix::carrying_one(issue) }}\n\ - \x20 }}\n\ - }}\n\n{SEAM}" - ), - )]); - assert_eq!(verdict.bodies, 0); - assert_eq!(verdict.roads, 0); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// A closed record nobody refuses with is an ordinary guarded type, and a - /// public constructor on one is ordinary. A reader that took every closed - /// record for a refusal body would refuse half the subsystem. - #[test] - fn a_closed_record_nobody_refuses_with_is_not_a_body() { - let verdict = mint_verdict(&[source_at( - "macros/macroc/src/home/types.rs", - "pub struct SpanTable {\n\ - \x20 positions: Vec,\n\ - }\n\ - \n\ - impl SpanTable {\n\ - \x20 pub fn issued(positions: Vec) -> Self {\n\ - \x20 Self { positions }\n\ - \x20 }\n\ - }\n", - )]); - assert_eq!(verdict.bodies, 0); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// A refusal body no road produces is an offence rather than a quiet pass. - /// The law's numerator over it is empty, so it guards nothing while the - /// printed denominator counts it as covered — which is the one failure a - /// derived population exists to prevent. - #[test] - fn a_body_no_road_produces_is_a_violation() { - let verdict = mint_verdict(&source("")); - assert_eq!(verdict.bodies, 1); - assert_eq!(verdict.roads, 0); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("no road in the subsystem hands one back")) - ); - } - - /// Planted reversal for the reader's own ceiling: a return type a macro - /// produces. Whether it hands a body across is UNKNOWN, and unknown is - /// reported rather than read as no — which is the difference between a - /// stated ceiling and a hole. - #[test] - fn an_unresolvable_return_shape_is_an_offence_rather_than_a_silence() { - let verdict = mint_verdict(&source("pub fn produced() -> answer!() { todo() }\n")); - assert_eq!( - verdict.offenders.len(), - 2, - "the macro road and the body no road produces: {:?}", - verdict.offenders - ); - assert!( - verdict - .offenders - .iter() - .any(|offence| offence.contains("unknown rather than false")) - ); - } - - /// A source this reader cannot parse is a hole in the population, and it is - /// reported as one. Silently reading it as "no bodies here" is the exact - /// failure the derived denominator exists to prevent. - #[test] - fn an_unparsable_source_is_an_offence_rather_than_an_absence() { - let verdict = mint_verdict(&[source_at( - "macros/macroc/src/home/types.rs", - "pub struct DemoRefusal {\n", - )]); - assert_eq!(verdict.bodies, 0); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!( - verdict - .offenders - .first() - .is_some_and(|offence| offence.contains("not parseable Rust")) - ); - } - - /// The real subsystem holds: every closed refusal body it declares is minted - /// only from inside the crate, and the derived population is real rather - /// than empty. - /// - /// The counts are asserted as RELATIONS and never as a number. A test - /// naming seven would be the hand-maintained inventory this law was written - /// to retire, moved one file over; the run prints the numbers, and the - /// relation is what has to hold. - #[test] - fn the_real_subsystem_mints_every_body_from_inside() { - let read = repo_root() - .map_err(|error| format!("the repository root could not be found: {error}")) - .and_then(|root| services_sources(&root)) - .map(|sources| mint_verdict(&sources)); - assert!( - read.is_ok(), - "the mint gate could not read its subject: {read:?}" - ); - assert!( - read.as_ref() - .is_ok_and(|verdict| verdict.offenders.is_empty()), - "{read:?}" - ); - assert!( - read.as_ref().is_ok_and(|verdict| verdict.bodies > 0), - "no closed refusal body found in the real subsystem: {read:?}" - ); - assert!( - read.is_ok_and(|verdict| verdict.roads >= verdict.bodies), - "a closed refusal body in the real subsystem is produced by no road at all" - ); - } -} diff --git a/xtask/src/checks/mod.rs b/xtask/src/checks/mod.rs index 97bfba2..d9a556d 100644 --- a/xtask/src/checks/mod.rs +++ b/xtask/src/checks/mod.rs @@ -18,15 +18,17 @@ pub(crate) mod dependency; pub(crate) mod supply_chain; +pub(crate) mod alarms; + pub(crate) mod placement; pub(crate) mod obligations; pub(crate) mod coupling; -pub(crate) mod mint; +pub(crate) mod seat; -pub(crate) mod seal; +pub(crate) mod positivity; pub(crate) mod vocabulary; diff --git a/xtask/src/checks/obligations.rs b/xtask/src/checks/obligations.rs index 826958a..5a13926 100644 --- a/xtask/src/checks/obligations.rs +++ b/xtask/src/checks/obligations.rs @@ -9,14 +9,16 @@ //! refuse it COUNTS, on two denominators printed on every run, because a debt //! that is stated out loud is a debt somebody can act on. -use std::fs; -use std::path::Path; +use crate::repository::markdown::{ObligationLedger, obligation_ledger, tooling_reversal_rows}; +use crate::repository::rust::DeclaredFunction; +use crate::repository::snapshot::{JUDGE_DIRECTORY, MACHINE_DIRECTORY, RepositorySnapshot}; +use crate::repository::types::{CanonicalPath, GreenRow, ObligationRecord, Read}; -use crate::repository::readme::{ - classify_green_rows, home_readmes, obligation_records, red_twin_rows, tooling_red_rows, -}; -use crate::repository::types::{GreenRow, ObligationRecord}; -use crate::repository::walk::{JUDGE_DIRECTORY, relative_slash_path, visit_files}; +/// The one compile-time proof surface the green seats are joined against. +const PROOF_SURFACE: &str = "src/laws.rs"; + +/// The document a home states its obligations in. +const HOME_LEDGER: &str = "README.md"; /// The READMEs that carry tooling qualification obligations. /// @@ -174,20 +176,29 @@ const CONTROL_MARKER: &str = "green:"; /// numbers are meant to be uncomfortable and are meant to be watched: a /// repository that quietly lost red twins would otherwise keep passing this /// check while the accounting shrank. -pub(crate) fn check_obligations_join(root: &Path) -> Result<(), String> { - let readmes = home_readmes(root)?; - let laws_path = root.join("src").join("laws.rs"); - let laws = fs::read_to_string(&laws_path).map_err(|e| format!("laws.rs: {e}"))?; +pub(crate) fn check_obligations_join(snapshot: &RepositorySnapshot) -> Result<(), String> { + let laws = snapshot + .rust() + .functions_in(&CanonicalPath::spelled(PROOF_SURFACE)) + .taken(PROOF_SURFACE)?; let existing = declared_laws(&laws); let mut claimed = Vec::new(); let mut rows = Vec::new(); let mut routes = Vec::new(); let mut unreadable = Vec::new(); let mut offenders = Vec::new(); - for readme in &readmes { - let text = fs::read_to_string(readme).map_err(|e| format!("{}: {e}", readme.display()))?; - let home = relative_slash_path(root, readme); - let declared = home_rows(&text, &home); + for home in home_readmes(snapshot) { + let document = snapshot.markdown().document(&home).taken(home.as_str())?; + let spelled = home.to_string(); + let ledger = obligation_ledger(document, &spelled) + .taken(&format!("{spelled}'s obligation ledger"))?; + offenders.extend( + document + .unjoinable_data_blocks() + .into_iter() + .map(|offence| format!("{spelled}: {offence}")), + ); + let declared = home_rows(&ledger, &spelled); offenders.extend(declared.offences); claimed.extend(declared.claimed); routes.extend(declared.routes); @@ -196,7 +207,7 @@ pub(crate) fn check_obligations_join(root: &Path) -> Result<(), String> { } offenders.extend(drifted_claim_offences(&claimed, &existing)); offenders.extend(double_claimed_offences(&claimed)); - let judge = testpak_populations(root)?; + let judge = testpak_populations(snapshot); let ledger = red_twin_ledger(&rows, &judge.reversals); offenders.extend(phantom_green_routes(&routes, &judge.seats)); offenders.extend(uncontrolled_green_routes(&routes, &judge.seats)); @@ -204,7 +215,7 @@ pub(crate) fn check_obligations_join(root: &Path) -> Result<(), String> { offenders.extend(unreadable_green_offences(&unreadable)); offenders.extend(judge.unparsable); - let tooling_rows = tooling_rows(root)?; + let tooling_rows = tooling_rows(snapshot)?; let tooling = red_twin_ledger(&tooling_rows, &judge.reversals); // TWO denominators, printed apart, always. The populations are challenged by @@ -233,6 +244,26 @@ pub(crate) fn check_obligations_join(root: &Path) -> Result<(), String> { } } +/// Every home README the join reads: the root one, and one per numbered band. +/// +/// Derived from the one reading rather than from a directory listing of its +/// own, in canonical path order, so the population is stable on every machine +/// and cannot differ from the population any other law is about. +fn home_readmes(snapshot: &RepositorySnapshot) -> Vec { + let mut homes = vec![CanonicalPath::spelled(HOME_LEDGER)]; + homes.extend( + snapshot + .files() + .under(MACHINE_DIRECTORY) + .map(|(path, _)| path) + .filter(|path| { + path.file_name() == HOME_LEDGER && path.as_str().matches('/').count() == 2 + }) + .cloned(), + ); + homes +} + /// Everything one home README declared, read through the obligation records /// that declared it. /// @@ -263,10 +294,10 @@ struct HomeRows { /// because the row was never separated from it. A green route carries that /// obligation onward, because the route leg has to ask a question about the /// CONTROL and not merely about the file. -fn home_rows(text: &str, home: &str) -> HomeRows { - let records = obligation_records(text); - let mut offences = record_field_offences(&records, home); - offences.extend(unowned_row_offences(text, &records, home)); +fn home_rows(ledger: &ObligationLedger, home: &str) -> HomeRows { + let records = ledger.records(); + let mut offences = record_field_offences(records, home); + offences.extend(ledger.offences().iter().cloned()); let mut declared = HomeRows { claimed: Vec::new(), routes: Vec::new(), @@ -275,22 +306,32 @@ fn home_rows(text: &str, home: &str) -> HomeRows { offences, }; for record in records { - let id = record.id; - declared - .red - .extend(record.red.into_iter().map(|row| (row, String::from(home)))); - for row in record.green { - match row { - GreenRow::CompileTimeSeat { module, law } => { - declared.claimed.push((module, law, String::from(home))); + let id = &record.id; + declared.red.extend( + record + .red + .iter() + .map(|row| (row.clone(), String::from(home))), + ); + for row in &record.green { + match *row { + GreenRow::CompileTimeSeat { + ref module, + ref law, + } => { + declared + .claimed + .push((module.clone(), law.clone(), String::from(home))); } - GreenRow::Route(named) => declared.routes.push(GreenRoute { - named, + GreenRow::Route(ref named) => declared.routes.push(GreenRoute { + named: named.clone(), readme: String::from(home), id: id.clone(), }), - GreenRow::Unreadable(value) => { - declared.unreadable.push((value, String::from(home))); + GreenRow::Unreadable(ref value) => { + declared + .unreadable + .push((value.clone(), String::from(home))); } GreenRow::Disposition => (), } @@ -318,8 +359,29 @@ struct GreenRoute { id: String, } -/// Every obligation record that does not state exactly one `green:` row and -/// exactly one `red:` row, one offence per missing or doubled field. +/// Every obligation record whose IDENTITY or whose rows do not stand, one +/// offence per defect. +/// +/// # The identity leg, which is B3's rule kept here rather than owed +/// +/// An obligation's id is the join key on three sides at once: the `green:` and +/// `red:` rows are attributed to it, [`uncontrolled_green_routes`] resolves a +/// routed seat's control marker against it, and a repair is made by finding the +/// record it names. So a record whose id is EMPTY has rows nothing can attribute +/// and a marker nothing can name, and two records in one home sharing an id are +/// two obligations one marker discharges — the doubled-evidence defect this +/// module refuses on both green sides, arriving through the key instead of +/// through the row. +/// +/// Neither is stated anywhere else, so leaving them to a later phase would leave +/// the join reading keys it never checked. Uniqueness is asked WITHIN one home, +/// which is the scope an id actually has: the id is written in a home's ledger, +/// resolved against that home's rows, and two homes may lawfully write the same +/// word. +/// +/// # And the row leg, unchanged +/// +/// Every record states exactly one `green:` row and exactly one `red:` row. /// /// The leg the row readers could not have. A row-shaped reader answers questions /// about the rows it was given, and a deleted row is the one thing it is never @@ -344,8 +406,31 @@ struct GreenRoute { /// than by deleting a row from a README the repository stands on. fn record_field_offences(records: &[ObligationRecord], readme: &str) -> Vec { let mut offences = Vec::new(); + let mut reported: Vec<&str> = Vec::new(); for record in records { let id = &record.id; + if id.is_empty() { + offences.push(format!( + "{readme}: an obligation record opens with `id:` and states no identity. The id is \ + the key its own rows are attributed to, the key a routed seat's control marker \ + names back, and the key a repair is found by — a record with none has rows \ + nothing can attribute and a marker nothing can name" + )); + } else if !reported.contains(&id.as_str()) + && records + .iter() + .filter(|other| other.id == *id) + .count() + .gt(&1) + { + reported.push(id.as_str()); + offences.push(format!( + "{readme}: obligation `{id}` is declared by {} records, and an id names one \ + obligation. Two records sharing a key are two claims one control marker \ + discharges, and nothing says which of them the evidence is about", + records.iter().filter(|other| other.id == *id).count() + )); + } if record.green.len() != 1 { let stated = record.green.len(); offences.push(format!( @@ -367,78 +452,53 @@ fn record_field_offences(records: &[ObligationRecord], readme: &str) -> Vec Vec { - let owned_green = records.iter().fold(0usize, |total, record| { - total.saturating_add(record.green.len()) - }); - let owned_red = records.iter().fold(0usize, |total, record| { - total.saturating_add(record.red.len()) - }); - let mut offences = Vec::new(); - for (stray, field) in [ - ( - classify_green_rows(text).len().saturating_sub(owned_green), - "green", - ), - (red_twin_rows(text).len().saturating_sub(owned_red), "red"), - ] { - if stray > 0 { - offences.push(format!( - "{readme}: {stray} `{field}:` row(s) stand outside every obligation record. This \ - join reads rows through the record that declared them, so a row no record owns \ - is joined by nothing and counted by nothing — the repair is to write it inside \ - the record it belongs to, indented beneath that record's own `- id:`" - )); - } - } - offences -} - -/// Every `#[test]` law `laws.rs` declares, as `(module, law)` in file order. -/// -/// A law is a `#[test]` function inside a `mod`, so the reading is the pair: the -/// module last opened at the crate root, and the function the attribute sits -/// on. Read as text rather than parsed, because what the join needs is the two -/// names, and a reader that needed a syntax tree to learn them would be a second -/// opinion about the same file. -fn declared_laws(laws_text: &str) -> Vec<(String, String)> { - let mut declared = Vec::new(); - let mut current_module = String::new(); - let mut previous_was_test = false; - for line in laws_text.lines() { - if let Some(rest) = line.strip_prefix("mod ") - && let Some(module) = rest.strip_suffix(" {") - { - current_module = module.to_string(); - } - if previous_was_test - && let Some(rest) = line.trim().strip_prefix("fn ") - && let Some(law) = rest.split('(').next() - { - declared.push((current_module.clone(), law.to_string())); - } - previous_was_test = line.trim() == "#[test]"; - } +/// Every `#[test]` law `laws.rs` declares, as `(module, law)` in declaration +/// order. +/// +/// A law is a function carrying the harness's own attribute, and its identity is +/// the pair: the module path it is declared inside, and the name it is declared +/// under. Both come off the ONE parse the snapshot already holds — item, module +/// path, complete attribute set — through the same +/// [`is_the_harness_attribute`] roster the seat population is read with, so +/// `laws.rs` and `testpak/tests/` are asked the same question by the same reader. +/// +/// # What the line reader this replaced could not see +/// +/// It required the attribute to be alone on the previous LINE and the function +/// to open the next one, so every shape below vanished from the denominator +/// while cargo went on executing it — and vanishing from this denominator is +/// silent in the direction that matters, because a law nobody claims is only +/// reported when the join knows the law exists: +/// +/// - `#[test]` followed by `#[should_panic]`, or by any other attribute, before +/// the function. +/// - `#[test]` written after `#[cfg]`, `#[expect]`, or a documentation comment, +/// which arrives as `#[doc = "…"]` on the same item. +/// - Anything declared inside a nested inline module, whose `mod` line the +/// reader matched only when it was written flush at the file's left edge, so a +/// nested law was attributed to the module ABOVE it. +/// +/// It also read laws that do not exist: `#[test]` written on its own line inside +/// a multi-line string literal opened a phantom law, which the drift leg then +/// reported as claimed by nobody. The parse answers both directions at once, +/// because a parse is about items rather than about lines. +/// +/// # The ceiling, and which way it falls +/// +/// This establishes what `laws.rs` DECLARES, not what a run executed. A law +/// carrying `#[ignore]`, or standing under a `#[cfg]` no build enables, is +/// declared and is counted here — deliberately, because the join's question is +/// whether the READMEs and the proof surface name the same population, and a law +/// dropped from this side stands unclaimed with nothing saying so. That a +/// declared law also RUNS is a claim the qualification run's test stage +/// establishes and this reader does not; it opens where every other ceiling in +/// this module opens, at the roster a run publishes of what it executed. +fn declared_laws(declared: &[DeclaredFunction<'_>]) -> Vec<(String, String)> { declared + .iter() + .filter(|function| function.attributes().iter().any(is_the_harness_attribute)) + .map(|function| (function.module().to_owned(), function.name().to_owned())) + .collect() } /// Where the READMEs and `laws.rs` have drifted apart, in both directions: a @@ -744,19 +804,18 @@ fn unreadable_green_offences(rows: &[(String, String)]) -> Vec { /// count with nothing said — and the emptiness guard downstream cannot see it, /// because the other file's rows keep the population non-empty. A ledger that /// shrinks quietly is the failure this whole join exists to refuse. -fn tooling_rows(root: &Path) -> Result, String> { +fn tooling_rows(snapshot: &RepositorySnapshot) -> Result, String> { let mut rows = Vec::new(); for readme in TOOLING_READMES { - let path = root.join(readme); - if !path.is_file() { - return Err(format!( - "{readme} is declared as a tooling obligation ledger and is not there: its rows \ - would leave the tooling denominator with nothing saying so" - )); - } - let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - for row in tooling_red_rows(&text) { - rows.push((row, relative_slash_path(root, &path))); + let path = CanonicalPath::spelled(readme); + let document = snapshot.markdown().document(&path).taken(&format!( + "{readme}, which is declared as a tooling obligation ledger and whose rows would \ + otherwise leave the tooling denominator with nothing saying so" + ))?; + let declared = tooling_reversal_rows(document) + .taken(&format!("{readme}'s tooling obligation ledger"))?; + for row in declared { + rows.push((row.clone(), String::from(readme))); } } Ok(rows) @@ -812,15 +871,6 @@ fn red_twin_ledger(rows: &[(String, String)], reversals: &ReversalPopulation) -> ledger } -/// Whether one visited file is a Rust source file. -/// -/// Read through `Path` rather than off the end of the string: asking the path -/// type for its extension is the reading that stays right on either platform, -/// and it is the only spelling the lint wall admits. -fn is_rust_file(path: &Path) -> bool { - path.extension().is_some_and(|extension| extension == "rs") -} - /// Every reversal testpak carries, as repository-relative slash paths: the test /// files directly under `testpak/tests/` AND the fixtures beneath them. /// @@ -948,44 +998,43 @@ struct JudgeTree { /// asked of them alone. A fixture beneath them is answered by its placement and /// its contents are none of this reader's business — several of them do not /// parse, and do not compile, by design. -fn testpak_populations(root: &Path) -> Result { - let tests = root.join(JUDGE_DIRECTORY).join("tests"); - if !tests.is_dir() { - return Ok(JudgeTree { - reversals: ReversalPopulation(Vec::new()), - seats: SeatPopulation(Vec::new()), - unparsable: Vec::new(), - }); - } +fn testpak_populations(snapshot: &RepositorySnapshot) -> JudgeTree { + let tests = format!("{JUDGE_DIRECTORY}/tests"); let mut reversals = Vec::new(); - let mut top_level = Vec::new(); - visit_files(&tests, &mut |path| { - if is_rust_file(path) { - let spelled = relative_slash_path(root, path); - if path.parent() == Some(tests.as_path()) { - let text = - fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - top_level.push((spelled.clone(), text)); - } - reversals.push(spelled); + let mut top_level: Vec<(&CanonicalPath, &syn::File)> = Vec::new(); + let mut unparsable = Vec::new(); + for (path, source) in snapshot.rust().under(&tests) { + reversals.push(path.to_string()); + if !path.sits_directly_in(&tests) { + continue; } - Ok(()) - })?; - let (seats, unparsable) = seat_population(&top_level); - Ok(JudgeTree { + match *source { + Read::Known(ref parsed) => top_level.push((path, parsed)), + Read::DeclaredAbsent(reason) => unparsable.push(format!( + "{path} sits directly under `{tests}/` and was not read, so whether it declares a \ + test that RUNS is unknown rather than false: {reason}" + )), + Read::Unreadable(ref failure) => unparsable.push(format!( + "{path} sits directly under `{tests}/` and is not parseable Rust, so whether it \ + declares a test that RUNS is unknown rather than false: {failure}" + )), + } + } + JudgeTree { reversals: ReversalPopulation(reversals), - seats, + seats: seat_population(&top_level), unparsable, - }) + } } -/// The seats among the top-level sources, and the ones whose seat question could -/// not be answered. +/// The seats among the top-level sources. /// -/// Pure over `(repository-relative path, source text)` pairs, so the reversal -/// for the narrowing is a source held in memory: the leg that decides what -/// counts as a positive control is never proven by writing an empty test file -/// into the judge. +/// Pure over `(canonical path, parsed tree)` pairs, so the reversal for the +/// narrowing is a source held in memory: the leg that decides what counts as a +/// positive control is never proven by writing an empty test file into the +/// judge. A source that did not parse never arrives here — the caller carries it +/// as an offence, because whether such a file declares a running test is UNKNOWN +/// rather than false. /// /// The FILE's own attributes are read before its items are, because the file is /// the outermost module a test can be enclosed by and this reader used to walk @@ -995,19 +1044,13 @@ fn testpak_populations(root: &Path) -> Result { /// saw the `#[test]` below and called it a seat. `syn` hands an inner attribute /// back on the item it is written inside, so the file, a module and a function /// are all asked the same question through the same reading. -fn seat_population(sources: &[(String, String)]) -> (SeatPopulation, Vec) { - let mut seats = Vec::new(); - let mut unparsable = Vec::new(); - for (path, text) in sources { - match syn::parse_file(text) { - Ok(file) => seats.extend(seat_of(path, &file)), - Err(error) => unparsable.push(format!( - "{path} sits directly under `testpak/tests/` and is not parseable Rust, so whether \ - it declares a test that RUNS is unknown rather than false: {error}" - )), - } - } - (SeatPopulation(seats), unparsable) +fn seat_population(sources: &[(&CanonicalPath, &syn::File)]) -> SeatPopulation { + SeatPopulation( + sources + .iter() + .filter_map(|(path, parsed)| seat_of(path.as_str(), parsed)) + .collect(), + ) } /// The seat one parsed top-level source is, or nothing where it is none. @@ -1449,17 +1492,111 @@ mod tests { use super::{ GreenRoute, JudgeTree, OWED_PREFIX, ReversalPopulation, Seat, SeatPopulation, declared_laws, double_claimed_offences, double_routed_offences, drifted_claim_offences, - phantom_green_routes, record_field_offences, red_twin_ledger, seat_population, - testpak_populations, tooling_rows, uncontrolled_green_routes, unowned_row_offences, - unreadable_green_offences, + home_readmes, phantom_green_routes, record_field_offences, red_twin_ledger, + seat_population as seats_of_trees, testpak_populations, tooling_rows, + uncontrolled_green_routes, unreadable_green_offences, }; - use crate::repository::readme::{ - classify_green_rows, home_readmes, obligation_records, red_twin_rows, tooling_red_rows, + use crate::checks::scratch::Scratch; + use crate::repository::markdown::{ + MarkdownDocument, ObligationLedger, obligation_ledger, tooling_reversal_rows, }; - use crate::repository::types::GreenRow; - use crate::repository::walk::{relative_slash_path, repo_root}; - use std::fs; - use std::path::{Path, PathBuf}; + use crate::repository::rust::declared_functions; + use crate::repository::snapshot::{RepositorySnapshot, repository_snapshot}; + use crate::repository::types::{CanonicalPath, GreenRow, ObligationRecord, Read}; + + /// The laws one fixture SOURCE declares, read the one way the join reads + /// them. + /// + /// A fixture is text, so this parses one and hands the parse to the same + /// reader the join uses. Nothing here re-implements the reading: a reversal + /// proven against a helper that agrees with the reader proves the helper. + fn laws_of(text: &str) -> Result, String> { + let file = syn::parse_file(text).map_err(|error| error.to_string())?; + Ok(declared_laws(&declared_functions(&file))) + } + + /// The obligation ledger one fixture states. + /// + /// A fixture is written as the data block a home writes, as the records + /// inside one, or as the rows inside a single record — and every one of them + /// is read HERE through the one reader the join itself uses, so a reversal is + /// proven against the reading rather than against a helper that agrees with + /// it. The wrapping is what a fixture omits, never what it states. + fn fixture_ledger(text: &str) -> ObligationLedger { + let written = if text.contains("```") { + String::from(text) + } else if text.contains("- id:") { + format!("```yaml\nhome: fixture\nobligations:\n{text}```\n") + } else { + format!( + "```yaml\nhome: fixture\nobligations:\n - id: fixture.the-one-record\n{text}```\n" + ) + }; + match obligation_ledger(&MarkdownDocument::parse(&written), "FIXTURE.md") { + Read::Known(ledger) => ledger, + Read::DeclaredAbsent(_) | Read::Unreadable(_) => ObligationLedger { + records: Vec::new(), + offences: Vec::new(), + }, + } + } + + /// Every obligation record one fixture declares. + fn obligation_records(text: &str) -> Vec { + fixture_ledger(text).records + } + + /// Every green row one fixture's records declare, classified. + fn classify_green_rows(text: &str) -> Vec { + obligation_records(text) + .into_iter() + .flat_map(|record| record.green) + .collect() + } + + /// Every red row one fixture's records declare. + fn red_twin_rows(text: &str) -> Vec { + obligation_records(text) + .into_iter() + .flat_map(|record| record.red) + .collect() + } + + /// Every `tooling-red:` row one fixture's tooling ledger declares. + fn tooling_red_rows(text: &str) -> Vec { + let written = if text.contains("```") { + String::from(text) + } else { + format!("```yaml\ntooling-obligation: fixture.the-one-obligation\n{text}```\n") + }; + match tooling_reversal_rows(&MarkdownDocument::parse(&written)) { + Read::Known(rows) => rows, + Read::DeclaredAbsent(_) | Read::Unreadable(_) => Vec::new(), + } + } + + /// The seat population fixture SOURCE TEXT declares, and the fixtures whose + /// seat question could not be answered. + /// + /// The law is handed trees the snapshot already parsed; a fixture is text, so + /// this parses one and reports a fixture that does not parse exactly as the + /// reading reports a source it could not read. + fn seat_population(sources: &[(String, String)]) -> (SeatPopulation, Vec) { + let mut parsed = Vec::new(); + let mut unparsable = Vec::new(); + for (path, text) in sources { + match syn::parse_file(text) { + Ok(file) => parsed.push((CanonicalPath::spelled(path), file)), + Err(error) => unparsable.push(format!( + "{path} sits directly under `testpak/tests/` and is not parseable Rust, so \ + whether it declares a test that RUNS is unknown rather than false: {error}" + )), + } + } + let trees: Vec<(&CanonicalPath, &syn::File)> = + parsed.iter().map(|(path, file)| (path, file)).collect(); + (seats_of_trees(&trees), unparsable) + } /// One synthetic `laws.rs` declaring exactly one law. const ONE_LAW: &str = "mod root {\n #[test]\n fn a_law_somebody_wrote() {}\n}\n"; @@ -1492,42 +1629,36 @@ mod tests { claims_in(readme_text, "FIXTURE.md") } - /// Every green row one README's obligation records declare, each carrying - /// the home and the obligation that wrote it. - /// - /// The record-aware reading, and the one every real-tree control below - /// reads through, because it is the reading the join itself uses: a row is - /// never separated from the obligation that declared it. - fn record_green_rows(readme_text: &str, home: &str) -> Vec<(GreenRow, String, String)> { + /// Every row the real repository's home READMEs declare, read through the + /// obligation record that declared it and attributed exactly as the join + /// attributes it. + fn real_rows(snapshot: &RepositorySnapshot) -> Result, String> { let mut declared = Vec::new(); - for record in obligation_records(readme_text) { - let id = record.id; - declared.extend( + for home in home_readmes(snapshot) { + let document = snapshot.markdown().document(&home).taken(home.as_str())?; + let spelled = home.to_string(); + let ledger = obligation_ledger(document, &spelled).taken(&spelled)?; + declared.extend(ledger.records.into_iter().flat_map(|record| { + let id = record.id; + let declaring = spelled.clone(); record .green .into_iter() - .map(|row| (row, String::from(home), id.clone())), - ); - } - declared - } - - /// Every claim the real repository's home READMEs make, read through the - /// obligation records that declared them and attributed exactly as the join - /// attributes them. - fn real_claims(root: &Path) -> Vec<(String, String, String)> { - let mut claimed = Vec::new(); - for readme in home_readmes(root).unwrap_or_default() { - let text = fs::read_to_string(&readme).unwrap_or_default(); - let home = relative_slash_path(root, &readme); - claimed.extend(record_green_rows(&text, &home).into_iter().filter_map( - |(row, home, _)| match row { - GreenRow::CompileTimeSeat { module, law } => Some((module, law, home)), - GreenRow::Disposition | GreenRow::Route(_) | GreenRow::Unreadable(_) => None, - }, - )); + .map(move |row| (row, declaring.clone(), id.clone())) + })); } - claimed + Ok(declared) + } + + /// Every claim the real repository's home READMEs make. + fn real_claims(snapshot: &RepositorySnapshot) -> Result, String> { + Ok(real_rows(snapshot)? + .into_iter() + .filter_map(|(row, home, _)| match row { + GreenRow::CompileTimeSeat { module, law } => Some((module, law, home)), + GreenRow::Disposition | GreenRow::Route(_) | GreenRow::Unreadable(_) => None, + }) + .collect()) } /// One synthetic row naming a route or reversal, attributed to a fixture. @@ -1580,13 +1711,9 @@ mod tests { .collect() } - /// The real judge tree, or empty populations where it could not be read. - fn real_tree(root: &Path) -> JudgeTree { - testpak_populations(root).unwrap_or_else(|_| JudgeTree { - reversals: red_population(&[]), - seats: green_population(&[]), - unparsable: Vec::new(), - }) + /// The real judge tree, or the refusal that says why it could not be read. + fn real_tree(snapshot: &RepositorySnapshot) -> JudgeTree { + testpak_populations(snapshot) } /// One synthetic top-level source, at a path directly under @@ -1634,11 +1761,10 @@ mod tests { } } - /// The routes one README's obligation records name, read the one way the - /// join reads them: through the record that declared the row, so the route - /// carries the obligation it is the positive control for. - fn routes_in(readme_text: &str, home: &str) -> Vec { - record_green_rows(readme_text, home) + /// Every green route the real repository's home READMEs name, attributed + /// exactly as the join attributes them. + fn real_routes(snapshot: &RepositorySnapshot) -> Result, String> { + Ok(real_rows(snapshot)? .into_iter() .filter_map(|(row, home, id)| match row { GreenRow::Route(named) => Some(route(&named, &home, &id)), @@ -1646,18 +1772,7 @@ mod tests { | GreenRow::Disposition | GreenRow::Unreadable(_) => None, }) - .collect() - } - - /// Every green route the real repository's home READMEs name, attributed - /// exactly as the join attributes them. - fn real_routes(root: &Path) -> Vec { - let mut routes = Vec::new(); - for readme in home_readmes(root).unwrap_or_default() { - let text = fs::read_to_string(&readme).unwrap_or_default(); - routes.extend(routes_in(&text, &relative_slash_path(root, &readme))); - } - routes + .collect()) } /// An owed row is lawful and counts as owed, whoever the named creditor is. @@ -1761,19 +1876,27 @@ mod tests { /// The real repository holds: every named red twin resolves to a reversal /// that exists, and the denominator is real rather than empty. #[test] - fn the_real_red_ledger_names_only_reversals_that_exist() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let reversals = real_tree(&root).reversals; + fn the_real_red_ledger_names_only_reversals_that_exist() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let reversals = real_tree(snapshot).reversals; assert!(!reversals.0.is_empty(), "testpak carries no reversal files"); let mut collected = Vec::new(); - let readmes = home_readmes(&root).unwrap_or_default(); + let readmes = home_readmes(snapshot); assert!(!readmes.is_empty(), "no home READMEs found"); for readme in &readmes { - let text = fs::read_to_string(readme).unwrap_or_default(); - let name = readme.display().to_string(); - for value in red_twin_rows(&text) { - collected.push((value, name.clone())); - } + let document = snapshot + .markdown() + .document(readme) + .taken(readme.as_str())?; + let name = readme.to_string(); + let ledger = obligation_ledger(document, &name).taken(&name)?; + collected.extend( + ledger + .records + .into_iter() + .flat_map(|record| record.red) + .map(|value| (value, name.clone())), + ); } let ledger = red_twin_ledger(&collected, &reversals); assert!(ledger.offenders.is_empty(), "{:?}", ledger.offenders); @@ -1781,6 +1904,7 @@ mod tests { ledger.owed > 0, "no owed red twins found; the ledger cannot be empty here" ); + Ok(()) } /// A tooling row is read off the trimmed line and counted on its OWN @@ -1823,39 +1947,35 @@ mod tests { /// guard never fires, because the other declared ledger keeps the population /// non-empty. /// - /// Read against a directory that is not the repository, which is every - /// declared ledger missing at once — the same reading the first missing one - /// gets, since the leg refuses on the first. + /// Read against a scratch tree carrying neither declared ledger, which is + /// every declared ledger missing at once — the same reading the first + /// missing one gets, since the leg refuses on the first. #[test] - fn a_missing_tooling_ledger_is_a_violation() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let elsewhere = root.join("xtask").join("src"); - let found = tooling_rows(&elsewhere); + fn a_missing_tooling_ledger_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("tooling-ledger-missing"); + scratch.write("README.md", "# a tree with no tooling ledger\n"); + let found = tooling_rows(&scratch.read()?); assert!(found.is_err(), "{found:?}"); assert!( found .err() .is_some_and(|offence| offence.contains("tooling obligation ledger")), ); + Ok(()) } /// The real tooling READMEs declare a non-empty denominator, and every row /// naming a reversal resolves to one that exists. #[test] - fn the_real_tooling_ledger_names_only_reversals_that_exist() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let reversals = real_tree(&root).reversals; - let mut collected = Vec::new(); - for readme in ["macros/macroc/README.md", "testpak/README.md"] { - let text = fs::read_to_string(root.join(readme)).unwrap_or_default(); - for row in tooling_red_rows(&text) { - collected.push((row, String::from(readme))); - } - } + fn the_real_tooling_ledger_names_only_reversals_that_exist() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let reversals = real_tree(snapshot).reversals; + let collected = tooling_rows(snapshot)?; assert!(!collected.is_empty(), "no tooling reversal rows found"); let ledger = red_twin_ledger(&collected, &reversals); assert!(ledger.offenders.is_empty(), "{:?}", ledger.offenders); assert!(ledger.owed > 0, "the tooling ledger claims no debt at all"); + Ok(()) } /// Planted reversal: a green route naming a positive control nobody wrote, @@ -1894,11 +2014,10 @@ mod tests { /// construction, so a row naming one is offering its own red twin — a proof /// of REFUSAL — as proof that the behavior works. #[test] - fn a_green_route_naming_a_fixture_is_a_violation() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); + fn a_green_route_naming_a_fixture_is_a_violation() -> Result<(), String> { let JudgeTree { reversals, seats, .. - } = real_tree(&root); + } = real_tree(repository_snapshot()?); for fixture in [ "testpak/tests/compile-fail/a-discarded-refusal.rs", @@ -1921,6 +2040,7 @@ mod tests { "{fixture} stood as a green positive control: {offered:?}" ); } + Ok(()) } /// Planted reversal: a green route spelled loosely rather than exactly — @@ -2876,28 +2996,28 @@ mod tests { /// population either unclassified or unowned, stated over the real tree /// rather than over a fixture. #[test] - fn the_real_green_rows_are_all_read() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let seats = real_tree(&root).seats; + fn the_real_green_rows_are_all_read() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let seats = real_tree(snapshot).seats; let mut routes = Vec::new(); let mut unreadable = Vec::new(); let mut seated = 0usize; let mut disposed = 0usize; let mut written = 0usize; - let mut declared = Vec::new(); - let readmes = home_readmes(&root).unwrap_or_default(); + let readmes = home_readmes(snapshot); assert!(!readmes.is_empty(), "no home READMEs found"); for readme in &readmes { - let text = fs::read_to_string(readme).unwrap_or_default(); - let name = relative_slash_path(&root, readme); written = written.saturating_add( - text.lines() + snapshot + .files() + .text(readme.as_str()) + .taken(readme.as_str())? + .lines() .filter(|line| line.trim().starts_with("green:")) .count(), ); - declared.extend(record_green_rows(&text, &name)); } - for (row, name, id) in declared { + for (row, name, id) in real_rows(snapshot)? { match row { GreenRow::Route(named) => routes.push(route(&named, &name, &id)), GreenRow::Unreadable(value) => unreadable.push((value, name)), @@ -2923,6 +3043,7 @@ mod tests { assert_eq!(read, written, "a green row was written and not read"); let found = phantom_green_routes(&routes, &seats); assert!(found.is_empty(), "{found:?}"); + Ok(()) } /// The real repository holds: the two populations are genuinely two. @@ -2938,13 +3059,12 @@ mod tests { /// question nobody answered, and it is reported rather than counted either /// way. #[test] - fn the_real_populations_are_named_apart() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); + fn the_real_populations_are_named_apart() -> Result<(), String> { let JudgeTree { reversals, seats, unparsable, - } = real_tree(&root); + } = real_tree(repository_snapshot()?); assert!(unparsable.is_empty(), "{unparsable:?}"); assert!(!seats.0.is_empty(), "testpak carries no executable seat"); assert!( @@ -2983,6 +3103,7 @@ mod tests { fixtures.len(), "a real fixture stood as a green positive control: {offered:?}" ); + Ok(()) } /// Planted reversal: two obligations pointing at one law. The second row's @@ -3057,12 +3178,12 @@ mod tests { /// The real repository holds: every green law it claims is claimed by /// exactly one obligation. #[test] - fn the_real_obligations_claim_each_law_once() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let claimed = real_claims(&root); + fn the_real_obligations_claim_each_law_once() -> Result<(), String> { + let claimed = real_claims(repository_snapshot()?)?; assert!(!claimed.is_empty(), "no green obligations found"); let found = double_claimed_offences(&claimed); assert!(found.is_empty(), "{found:?}"); + Ok(()) } /// Planted reversal: two obligations naming one executable seat. @@ -3151,15 +3272,111 @@ mod tests { /// is clean rather than a workout for the leg — the leg's own reversals are /// fixture rows, which is why they are written above and not here. #[test] - fn the_real_obligations_route_to_each_seat_once() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let routes = real_routes(&root); + fn the_real_obligations_route_to_each_seat_once() -> Result<(), String> { + let routes = real_routes(repository_snapshot()?)?; assert!( !routes.is_empty(), "no green route found; the leg would be guarding nothing" ); let found = double_routed_offences(&routes); assert!(found.is_empty(), "{found:?}"); + Ok(()) + } + + /// One synthetic `laws.rs` written in every shape the line reader could not + /// see, and two it saw wrongly. + /// + /// Every law here is ordinary Rust the harness collects and runs. Not one of + /// them reached the join's denominator while the reading was a line scan. + /// + /// The whole constant is fixture TEXT: `syn` parses it and no compiler ever + /// sees it, so the `#[expect]` written below is one of the shapes under test + /// rather than a lint hatch in this crate. `xtask` carries none of those, and + /// the wall that forbids them is the compiler's rather than a scan's — which + /// is the same distinction this reversal is about. + const LAWS_A_LINE_READER_CANNOT_SEE: &str = r#" +mod root { + /// A law whose documentation stands above its attribute. + #[test] + fn a_documented_law() {} + + #[test] + #[should_panic = "the shape reversed"] + fn a_law_that_must_panic() {} + + #[cfg(feature = "nothing-enables-this")] + #[test] + fn a_law_under_a_condition() {} + + #[expect(clippy::assertions_on_constants, reason = "the assertion is the law")] + #[test] + fn a_law_under_an_expectation() {} + + fn a_function_that_is_no_law() {} + + const SPELLED_IN_A_STRING: &str = " +#[test] +fn a_law_nobody_declared() {} +"; + + mod deeper { + #[test] + fn a_law_one_module_further_in() {} + } +} +"#; + + /// Planted reversal: every law shape the line reader dropped, and every + /// non-law it would have picked up. + /// + /// THE defect this reader replacement exists for, and it was silent in the + /// direction that matters. The scan required `#[test]` to stand alone on the + /// previous line with the function opening the next one, so a second + /// attribute, a documentation comment, or a condition written above the + /// attribute took the law out of the denominator entirely — while cargo went + /// on collecting and running it. A law missing from this side is a law no + /// obligation has to claim, so the README could drop its row and the drift + /// leg would have nothing to say: the population shrank and the join + /// reported clean about it. + /// + /// The two directions it got wrong the other way are here too. A nested + /// module's `mod` line was matched only flush at the file's left edge, so a + /// law one module further in was attributed to the module ABOVE it and + /// resolved against a target nobody wrote. And `#[test]` written on its own + /// line inside a multi-line string literal opened a law that does not exist. + /// + /// A parse answers all five at once, because a parse is about items and + /// their attributes rather than about lines. + #[test] + fn every_shape_the_line_reader_dropped_is_a_declared_law() -> Result<(), String> { + let declared = laws_of(LAWS_A_LINE_READER_CANNOT_SEE)?; + let spelled: Vec = declared + .iter() + .map(|(module, law)| format!("{module}::{law}")) + .collect(); + let found: Vec<&str> = spelled.iter().map(String::as_str).collect(); + assert_eq!( + found, + vec![ + "root::a_documented_law", + "root::a_law_that_must_panic", + "root::a_law_under_a_condition", + "root::a_law_under_an_expectation", + "root::deeper::a_law_one_module_further_in", + ], + "a law shape the harness runs is missing from the denominator" + ); + assert!( + !found.contains(&"root::a_law_nobody_declared"), + "a law was read out of a string literal: {found:?}" + ); + assert!( + !found + .iter() + .any(|named| named.ends_with("a_function_that_is_no_law")), + "a function carrying no harness attribute entered the denominator: {found:?}" + ); + Ok(()) } /// Planted reversal: an obligation claiming a law nobody wrote, spelled the @@ -3174,8 +3391,8 @@ mod tests { /// count of rows read still matched the count of rows written. There is one /// reader now, and each of these arrives at the leg that refuses it. #[test] - fn a_seat_the_strict_reader_dropped_still_reaches_the_join() { - let existing = declared_laws(ONE_LAW); + fn a_seat_the_strict_reader_dropped_still_reaches_the_join() -> Result<(), String> { + let existing = laws_of(ONE_LAW)?; assert_eq!(existing.len(), 1, "{existing:?}"); for spelled in [ " green:laws.rs root::a_law_nobody_wrote\n", @@ -3193,6 +3410,7 @@ mod tests { "{spelled:?}: {found:?}" ); } + Ok(()) } /// Planted reversal: a seat row carrying a token AFTER its target, read end @@ -3208,7 +3426,7 @@ mod tests { /// other side. One row spelled wrong is answered twice rather than passing /// twice. #[test] - fn a_seat_row_carrying_more_than_its_target_reaches_the_join() { + fn a_seat_row_carrying_more_than_its_target_reaches_the_join() -> Result<(), String> { let spelled = " green: laws.rs root::a_law_somebody_wrote and a word nobody read\n"; let claimed = seat_claims(spelled); assert!( @@ -3237,13 +3455,14 @@ mod tests { // The law the discarded token used to leave claimed is now claimed by // nobody, and the drift leg says so rather than letting the README shrink // quietly. - let drifted = drifted_claim_offences(&claimed, &declared_laws(ONE_LAW)); + let drifted = drifted_claim_offences(&claimed, &laws_of(ONE_LAW)?); assert!( drifted .iter() .any(|offence| offence.contains("claimed by no obligation")), "{drifted:?}" ); + Ok(()) } /// The real repository holds: the tooling ledgers' name-then-prose rows all @@ -3257,11 +3476,10 @@ mod tests { /// and shrink a denominator this repository publishes on every run, so the /// asymmetry is held down by a control rather than left to be tidied up. #[test] - fn the_real_tooling_rows_name_a_reversal_before_their_prose() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let reversals = real_tree(&root).reversals; - let continued: Vec<(String, String)> = tooling_rows(&root) - .unwrap_or_default() + fn the_real_tooling_rows_name_a_reversal_before_their_prose() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let reversals = real_tree(snapshot).reversals; + let continued: Vec<(String, String)> = tooling_rows(snapshot)? .into_iter() .filter(|(value, _)| { !value.starts_with(OWED_PREFIX) && value.split_whitespace().count() > 1 @@ -3280,14 +3498,15 @@ mod tests { continued.len(), "a tooling row naming its reversal and then speaking prose stopped resolving" ); + Ok(()) } /// Planted reversal: the other direction of the same drift — a law standing /// in `laws.rs` that no obligation claims, which is a proof outliving the /// claim it was written for. #[test] - fn a_law_no_obligation_claims_is_a_violation() { - let found = drifted_claim_offences(&[], &declared_laws(ONE_LAW)); + fn a_law_no_obligation_claims_is_a_violation() -> Result<(), String> { + let found = drifted_claim_offences(&[], &laws_of(ONE_LAW)?); assert_eq!(found.len(), 1, "{found:?}"); assert!( found @@ -3295,17 +3514,19 @@ mod tests { .is_some_and(|offence| offence.contains("claimed by no obligation")), "{found:?}" ); + Ok(()) } /// The positive control: a claim and the law that answers it are no offence /// in either direction. A leg that flagged everything would satisfy both /// reversals above and be worthless. #[test] - fn a_claim_and_the_law_answering_it_are_lawful() { + fn a_claim_and_the_law_answering_it_are_lawful() -> Result<(), String> { let claimed = seat_claims(" green: laws.rs root::a_law_somebody_wrote\n"); assert_eq!(claimed.len(), 1, "{claimed:?}"); - let found = drifted_claim_offences(&claimed, &declared_laws(ONE_LAW)); + let found = drifted_claim_offences(&claimed, &laws_of(ONE_LAW)?); assert!(found.is_empty(), "{found:?}"); + Ok(()) } /// One fixture home README carrying two whole obligation records. @@ -3418,45 +3639,121 @@ mod tests { assert!(found.is_empty(), "{found:?}"); } + /// Planted reversal: a record with no identity at all, and two records in + /// one home sharing one. + /// + /// The identity leg, and both halves are the same defect the row legs refuse + /// arriving through the KEY instead of through the row. An id is what a + /// record's own rows are attributed to and what a routed seat's control + /// marker names back, so an empty one leaves rows nothing can attribute and + /// a marker nothing can name, and a shared one lets one marker discharge two + /// claims — which is exactly what [`double_routed_offences`] and + /// [`double_claimed_offences`] refuse on the evidence side, unrefused on the + /// claim side. + /// + /// Pure over its records, so the leg is proven against fixture records + /// rather than by emptying an id in a README the repository stands on. + #[test] + fn a_record_with_no_identity_or_a_shared_one_is_a_violation() { + let nameless = "```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 - id:\n\ + \x20 green: laws.rs bounds::budget_is_affine\n\ + \x20 red: owed-to-testpak\n\ + ```\n"; + let found = record_field_offences(&obligation_records(nameless), "src/05_bounds/README.md"); + assert_eq!(found.len(), 1, "{found:?}"); + assert!( + found + .first() + .is_some_and(|offence| offence.contains("states no identity")), + "{found:?}" + ); + + let shared = "```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 - id: bounds.one-key-two-claims\n\ + \x20 green: laws.rs bounds::budget_is_affine\n\ + \x20 red: owed-to-testpak\n\ + \x20 - id: bounds.one-key-two-claims\n\ + \x20 green: laws.rs bounds::charge_shrinks_or_refuses\n\ + \x20 red: owed-to-testpak\n\ + ```\n"; + let doubled = record_field_offences(&obligation_records(shared), "src/05_bounds/README.md"); + assert_eq!(doubled.len(), 1, "one offence per shared key: {doubled:?}"); + assert!( + doubled.first().is_some_and(|offence| { + offence.contains("`bounds.one-key-two-claims`") + && offence.contains("declared by 2 records") + }), + "{doubled:?}" + ); + } + + /// The real repository holds: every obligation names itself, and no two + /// records in one home name the same thing. + /// + /// Stated over the tree because the identity leg is new and a leg that + /// arrives already refusing something is a leg somebody will weaken. It + /// found nothing: every record in every home README states an id, and every + /// id within a home is written once. + #[test] + fn the_real_records_each_name_themselves_once() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let mut offences = Vec::new(); + let mut records = 0_usize; + for path in home_readmes(snapshot) { + let home = path.to_string(); + let document = snapshot.markdown().document(&path).taken(&home)?; + let ledger = obligation_ledger(document, &home).taken(&home)?; + records = records.saturating_add(ledger.records.len()); + offences.extend(record_field_offences(&ledger.records, &home)); + } + assert!(records > 1, "the leg would be guarding nothing: {records}"); + assert!(offences.is_empty(), "{offences:?}"); + Ok(()) + } + /// Planted reversal: rows written where no obligation record owns them. /// - /// The repair's own failure mode, refused rather than trusted. This join now + /// The reading's own failure mode, refused rather than trusted. This join /// reads rows THROUGH the record that declared them, so a row standing /// outside every record is a row nothing joins — which would be the original - /// silence arriving through the repair itself. The counts are taken by the - /// same readers over both scopes, and the difference is named against the - /// README that wrote it. + /// silence arriving through the repair itself. The reading names it against + /// the README that wrote it rather than dropping it. #[test] fn a_row_no_record_owns_is_a_violation() { - let text = "green: laws.rs bounds::a_row_above_every_record\n\ - red: owed-to-testpak\n\ + let text = "```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 green: laws.rs bounds::a_row_above_every_record\n\ + \x20 red: owed-to-testpak\n\ \x20 - id: bounds.the-one-real-record\n\ \x20 green: laws.rs bounds::budget_is_affine\n\ - \x20 red: owed-to-testpak\n"; - let records = obligation_records(text); - let found = unowned_row_offences(text, &records, "src/05_bounds/README.md"); - assert_eq!(found.len(), 2, "{found:?}"); - assert!( - found - .first() - .is_some_and(|offence| offence.contains("1 `green:` row(s) stand outside")), - "{found:?}" - ); + \x20 red: owed-to-testpak\n\ + ```\n"; + let ledger = fixture_ledger(text); + assert_eq!(ledger.records.len(), 1, "{}", ledger.records.len()); + assert_eq!(ledger.offences.len(), 2, "{:?}", ledger.offences); assert!( - found - .last() - .is_some_and(|offence| offence.contains("1 `red:` row(s) stand outside")), - "{found:?}" + ledger + .offences + .iter() + .all(|offence| offence.contains("stands outside every obligation record")), + "{:?}", + ledger.offences ); } /// The positive control: rows written inside the records that declared them - /// are owned, and the leg says nothing. + /// are owned, and the reading says nothing. #[test] fn rows_written_inside_their_records_are_owned() { - let records = obligation_records(TWO_WHOLE_RECORDS); - let found = unowned_row_offences(TWO_WHOLE_RECORDS, &records, "src/05_bounds/README.md"); - assert!(found.is_empty(), "{found:?}"); + let ledger = fixture_ledger(TWO_WHOLE_RECORDS); + assert_eq!(ledger.records.len(), 2, "{}", ledger.records.len()); + assert!(ledger.offences.is_empty(), "{:?}", ledger.offences); } /// The real repository holds: every obligation is a whole record, and every @@ -3469,21 +3766,27 @@ mod tests { /// its record, moves the two sides apart instead of quietly moving the /// published figure. #[test] - fn the_real_records_are_whole_and_own_every_row() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let readmes = home_readmes(&root).unwrap_or_default(); + fn the_real_records_are_whole_and_own_every_row() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let readmes = home_readmes(snapshot); assert!(!readmes.is_empty(), "no home READMEs found"); let mut declared = 0usize; let mut ledger_rows = 0usize; let mut offences = Vec::new(); for readme in &readmes { - let text = fs::read_to_string(readme).unwrap_or_default(); - let home = relative_slash_path(&root, readme); - let records = obligation_records(&text); - declared = declared.saturating_add(records.len()); - ledger_rows = ledger_rows.saturating_add(red_twin_rows(&text).len()); - offences.extend(record_field_offences(&records, &home)); - offences.extend(unowned_row_offences(&text, &records, &home)); + let document = snapshot + .markdown() + .document(readme) + .taken(readme.as_str())?; + let home = readme.to_string(); + let ledger = obligation_ledger(document, &home).taken(&home)?; + declared = declared.saturating_add(ledger.records.len()); + ledger_rows = + ledger_rows.saturating_add(ledger.records.iter().fold(0usize, |total, record| { + total.saturating_add(record.red.len()) + })); + offences.extend(record_field_offences(&ledger.records, &home)); + offences.extend(ledger.offences); } assert!(offences.is_empty(), "{offences:?}"); assert!( @@ -3495,6 +3798,7 @@ mod tests { "{declared} obligation records declare {ledger_rows} red rows; the published core \ denominator is one row per record" ); + Ok(()) } /// Planted reversal: a routed seat that exists, runs tests, and holds no @@ -3732,10 +4036,10 @@ mod tests { /// that the three are one identity. A rename at either end fails this test /// by name. #[test] - fn the_real_route_resolves_to_the_control_it_names() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let seats = real_tree(&root).seats; - let routes = real_routes(&root); + fn the_real_route_resolves_to_the_control_it_names() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let seats = real_tree(snapshot).seats; + let routes = real_routes(snapshot)?; assert!( !routes.is_empty(), "no green route found; the leg would be guarding nothing" @@ -3756,6 +4060,7 @@ mod tests { ), "the routed seat no longer documents the obligation it controls" ); + Ok(()) } /// The real repository holds: the seats its READMEs write and the laws @@ -3769,13 +4074,41 @@ mod tests { /// population with nothing saying so. There is one reader now, so this /// states what it found against the file it is joined to, and a seat that /// stops resolving moves the number rather than hiding behind it. + /// + /// # The denominator is PINNED, because a reader was replaced + /// + /// ONE HUNDRED AND EIGHTY-FOUR laws, and the number is written here rather + /// than merely compared against the claims. The line reader this join used + /// to call could not see a law carrying a second attribute, a documentation + /// comment, or a nested module, and could read a law out of a string literal + /// — so replacing it with a parse could have moved this population in either + /// direction with both sides moving together and nothing saying so. Measured + /// across the replacement: 183 before, 183 after, every pair identical. The + /// reader changed and the tree did not, which is the only way a reader + /// replacement is allowed to settle. + /// + /// THE NUMBER HAS MOVED ONCE SINCE, BY ONE, AND THE LAW IS NAMED. Joining + /// the capacity-authority boundary added `root::a_family_declares_one_ + /// capacity_authority`, so this population is 184. That is what a pinned + /// denominator is for: two boundaries settled apart, one of them grew the + /// proof surface, and the number refused the join until somebody said which + /// law arrived. A count that moved silently here would have been the same + /// defect the reader replacement was written to end. #[test] - fn the_real_seats_are_the_real_laws() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let claimed = real_claims(&root); - let laws = fs::read_to_string(root.join("src").join("laws.rs")).unwrap_or_default(); + fn the_real_seats_are_the_real_laws() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let claimed = real_claims(snapshot)?; + let laws = snapshot + .rust() + .functions_in(&CanonicalPath::spelled(super::PROOF_SURFACE)) + .taken(super::PROOF_SURFACE)?; let existing = declared_laws(&laws); - assert!(!existing.is_empty(), "laws.rs declares no law"); + assert_eq!( + existing.len(), + 184, + "laws.rs declares {} laws", + existing.len() + ); assert_eq!( claimed.len(), existing.len(), @@ -3785,5 +4118,6 @@ mod tests { ); let found = drifted_claim_offences(&claimed, &existing); assert!(found.is_empty(), "{found:?}"); + Ok(()) } } diff --git a/xtask/src/checks/parity.rs b/xtask/src/checks/parity.rs index 74a9aae..5b2d149 100644 --- a/xtask/src/checks/parity.rs +++ b/xtask/src/checks/parity.rs @@ -4,18 +4,26 @@ //! different hands. Byte-identity is the only claim that survives that: any //! weaker comparison lets the two drift into two laws that both look official. -use std::fs; -use std::path::Path; +use crate::repository::snapshot::RepositorySnapshot; + +/// The working law, in the two files that carry it. +const WORKING_LAW: [&str; 2] = ["AGENTS.md", "CLAUDE.md"]; /// `AGENTS.md` and `CLAUDE.md` carry the same working law and must stay /// byte-identical. -pub(crate) fn check_agents_claude_parity(root: &Path) -> Result<(), String> { - let agents = fs::read(root.join("AGENTS.md")).map_err(|e| format!("AGENTS.md: {e}"))?; - let claude = fs::read(root.join("CLAUDE.md")).map_err(|e| format!("CLAUDE.md: {e}"))?; - if agents == claude { +/// +/// Both are read out of the one snapshot, so the comparison is between the +/// bytes one reading took — not between two reads that could have happened at +/// two moments. +pub(crate) fn check_agents_claude_parity(snapshot: &RepositorySnapshot) -> Result<(), String> { + let mut carried = Vec::new(); + for named in WORKING_LAW { + carried.push(snapshot.files().bytes(named).taken(named)?); + } + if carried.windows(2).all(|pair| pair.first() == pair.last()) { Ok(()) } else { - Err(String::from("AGENTS.md and CLAUDE.md differ")) + Err(format!("{} and {} differ", WORKING_LAW[0], WORKING_LAW[1])) } } @@ -27,14 +35,30 @@ mod tests { /// Planted reversal: the two working-law files drift apart. One of them /// edited alone is exactly how a working law stops being one law. #[test] - fn a_drifted_working_law_pair_is_a_violation() { + fn a_drifted_working_law_pair_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("agents-parity"); scratch.write("AGENTS.md", "the working law\n"); scratch.write("CLAUDE.md", "the working law\n"); - assert!(check_agents_claude_parity(scratch.root()).is_ok()); + assert!(check_agents_claude_parity(&scratch.read()?).is_ok()); scratch.write("CLAUDE.md", "the working law, edited on one side only\n"); - let found = check_agents_claude_parity(scratch.root()); + let found = check_agents_claude_parity(&scratch.read()?); assert!(found.is_err_and(|reason| reason.contains("differ"))); + Ok(()) + } + + /// Planted reversal: one of the two files is not there at all. Absence is + /// UNKNOWN rather than agreement — a reading that answered a missing file + /// with empty bytes would have found two empty files identical. + #[test] + fn a_missing_half_of_the_working_law_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("agents-parity-missing"); + scratch.write("AGENTS.md", "the working law\n"); + let found = check_agents_claude_parity(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("CLAUDE.md") && reason.contains("not there")), + "a missing working-law file read as agreement" + ); + Ok(()) } } diff --git a/xtask/src/checks/placement.rs b/xtask/src/checks/placement.rs index 3adfc7d..52bc14d 100644 --- a/xtask/src/checks/placement.rs +++ b/xtask/src/checks/placement.rs @@ -8,58 +8,138 @@ //! order. A services home may be a file or a directory, and the check reads both //! the same way. In both crates the map and the crate are derived from each //! other rather than maintained by hand, which is why neither can quietly drift. +//! +//! Both readings go through the decoders that own what they are reading. A +//! declared module is an ITEM, so `syn` is asked which items a crate root +//! declares and in what order; a reference is a PATH in the token stream, so +//! `proc-macro2` is asked to lex it. The line reader this replaced discovered +//! modules by matching `mod ` at the head of a trimmed line and found the band +//! declarations with `str::find` on an attribute spelled exactly one way — so a +//! module declared across two lines was invisible to it, and an attribute +//! written with different spacing was a band `lib.rs` "did not declare". +//! +//! # Both populations are TOTAL over their directory +//! +//! Each law is about a directory, so each classifies every direct child of that +//! directory into exactly one named state and refuses the state that means +//! "nothing here recognized this". Both readings used to RECOGNIZE their subject +//! — a directory opening with two digits and an underscore, a `mod` declaration +//! carrying no build condition — and let everything else fall through a silent +//! `continue`. That is not a narrow claim, it is an unstated one: a misspelled +//! architectural directory and a source no declaration accounts for each left +//! the population with nothing said anywhere, and both checks went on printing +//! PASS while guarding less than the sentence above them says. +//! +//! Neither law admits a conditionally-compiled production module, and the reason +//! is the same in both crates. These readings establish ONE order. A module +//! compiled in some builds and not others stands in as many orders as there are +//! build populations, and this reading can establish none of them — so the one +//! condition either law admits is the proof surface's exact `#[cfg(test)]`, and +//! every other conditioned declaration refuses rather than quietly leaving the +//! order. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use proc_macro2::{Delimiter, TokenStream, TokenTree}; + +use crate::repository::snapshot::{MACHINE_DIRECTORY, RepositorySnapshot}; +use crate::repository::types::{CanonicalPath, ModuleLayout}; + +/// The files a numbered band home carries. +const HOME_FILES: [&str; 3] = ["README.md", "mod.rs", "types.rs"]; + +/// The crate root of the machine. +const MACHINE_ROOT: &str = "src/lib.rs"; + +/// The services crate's source directory, whose unnumbered module list carries +/// its dependency order the way numbered directories carry the machine's. +const TOOLING_SOURCE: &str = "macros/macroc/src"; + +/// The file cargo compiles a crate from. It declares the order rather than +/// standing in it, in both crates. +const CRATE_ROOT_FILE: &str = "lib.rs"; + +/// The extension a Rust source carries. +const SOURCE_SUFFIX: &str = ".rs"; + +/// The files the machine's crate root reserves beside it: the root's own public +/// types, and the residue proof surface. Neither is a semantic home, and neither +/// is an accident — the working law seats both at the root by name. +const RESERVED_ROOT_FILES: [&str; 2] = ["laws.rs", "types.rs"]; + +/// The attribute a band declaration carries. +const PATH_ATTRIBUTE: &str = "path"; -use std::fs; -use std::path::Path; +/// The attribute that takes a declaration out of the order it would otherwise +/// stand in. +const CONDITION_ATTRIBUTE: &str = "cfg"; -use crate::repository::types::ModuleLayout; -use crate::repository::walk::module_source; +/// The attribute that would attach a build condition to a declaration +/// indirectly, and which therefore conditions it just as surely. +const CONDITIONED_ATTRIBUTE: &str = "cfg_attr"; + +/// The one build condition either order admits: the proof surface's own. +const PROOF_SURFACE_CONDITION: &str = "test"; /// Every numbered band directory is complete (README.md, mod.rs, types.rs) and /// `lib.rs` declares every band via its `#[path]` attribute in ascending band /// order — the band map and the crate never drift apart. -pub(crate) fn check_band_map(root: &Path) -> Result<(), String> { - let src = root.join("src"); +/// +/// # Why every child of `src/` is classified rather than only the numbered ones +/// +/// A band was once RECOGNIZED — a direct child whose first component opened with +/// two digits and an underscore — and everything the recognizer did not match +/// fell through a silent `continue`. That is not a narrow population, it is an +/// unstated one: `src/O5_bounds/`, `src/5_bounds/`, `src/bounds/` and +/// `src/notes.md` each leave the band map without a word said anywhere, and the +/// check that reports on the band map keeps printing PASS. So the classification +/// is TOTAL. Every direct child of `src/` is exactly one of four things — a +/// numbered semantic home, the crate root, a file the root grammar reserves, or +/// an invalid entry — and the fourth is a refusal naming what it found. +pub(crate) fn check_band_map(snapshot: &RepositorySnapshot) -> Result<(), String> { + let mut offenders = Vec::new(); let mut bands = Vec::new(); - let entries = fs::read_dir(&src).map_err(|e| format!("{}: {e}", src.display()))?; - for entry in entries { - let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?; - if !entry - .file_type() - .map_err(|e| format!("{}: {e}", src.display()))? - .is_dir() - { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let Some((number, _)) = name.split_once('_') else { - continue; - }; - if number.len() == 2 && number.chars().all(|c| c.is_ascii_digit()) { - bands.push(name); + for child in machine_source_children(snapshot).values() { + match *child { + MachineSourceChild::NumberedHome(ref named) => bands.push(named.clone()), + MachineSourceChild::CrateRoot | MachineSourceChild::ReservedRootFile => (), + MachineSourceChild::Invalid(ref reason) => offenders.push(reason.clone()), } } - bands.sort(); - let mut offenders = Vec::new(); + if bands.is_empty() { + return Err(String::from( + "no numbered band directory was found: this denominator cannot be empty while the \ + machine states its dependency bands with numbered directories, so the reader is \ + looking at the wrong tree", + )); + } for band in &bands { - for file in ["README.md", "mod.rs", "types.rs"] { - if !src.join(band).join(file).is_file() { + for file in HOME_FILES { + if snapshot + .files() + .get(&format!("{MACHINE_DIRECTORY}/{band}/{file}")) + .is_none() + { offenders.push(format!("{band} missing {file}")); } } } - let lib = fs::read_to_string(src.join("lib.rs")).map_err(|e| format!("lib.rs: {e}"))?; - let mut declared_positions = Vec::new(); + let root = snapshot + .rust() + .source(&CanonicalPath::spelled(MACHINE_ROOT)) + .taken(MACHINE_ROOT)?; + let declared = band_declarations(root, &mut offenders); + let mut positions = Vec::new(); for band in &bands { - let needle = format!("#[path = \"{band}/mod.rs\"]"); - match lib.find(&needle) { - Some(position) => declared_positions.push((position, band.clone())), + match declared.iter().position(|stated| stated == band) { + Some(position) => positions.push((position, band.clone())), None => offenders.push(format!("lib.rs does not declare {band}")), } } - let mut sorted = declared_positions.clone(); - sorted.sort(); - if sorted != declared_positions { + let mut ascending = positions.clone(); + ascending.sort(); + if ascending != positions { offenders.push(String::from( "lib.rs band declarations are out of band order", )); @@ -71,9 +151,201 @@ pub(crate) fn check_band_map(root: &Path) -> Result<(), String> { } } -/// The services crate's source directory, whose unnumbered module list carries -/// its dependency order the way numbered directories carry the machine's. -const TOOLING_MODULE_ROOT: [&str; 3] = ["macros", "macroc", "src"]; +/// What one direct child of the machine's source directory is. +/// +/// Four states, total over the tree: nothing under `src/` is outside them, which +/// is exactly what naming them buys. An entry that matches none of the first +/// three is the fourth and says so, rather than leaving the population by a +/// route nobody reads. +#[derive(Debug, PartialEq, Eq)] +enum MachineSourceChild { + /// `NN_name/` — a numbered semantic home, carrying the band's own files. + NumberedHome(String), + /// `lib.rs` — the crate root, the one file cargo compiles the machine from. + CrateRoot, + /// A file the crate root's own grammar reserves beside it. + ReservedRootFile, + /// Anything else, carried with the words the refusal is written in. + Invalid(String), +} + +/// Every direct child of the machine's source directory, classified, keyed by +/// the child's own name so the bands come back in ascending band order. +/// +/// Derived from the one reading: the children are the distinct first components +/// of the paths beneath `src/`, and a path this reader cannot cut into one is +/// itself an entry rather than a skip. +fn machine_source_children(snapshot: &RepositorySnapshot) -> BTreeMap { + let mut children = BTreeMap::new(); + for (path, _) in snapshot.files().under(MACHINE_DIRECTORY) { + let Some(tail) = path + .as_str() + .get(MACHINE_DIRECTORY.len().saturating_add(1)..) + else { + children.insert( + path.to_string(), + MachineSourceChild::Invalid(format!( + "`{path}` sits beneath {MACHINE_DIRECTORY}/ and this reader cannot cut it into \ + a direct child, so what it is under the machine's source directory is unknown \ + rather than nothing" + )), + ); + continue; + }; + match tail.split_once('/') { + Some((head, _)) => { + children.insert(head.to_owned(), classified_directory(head)); + } + None => { + children.insert(tail.to_owned(), classified_root_file(tail)); + } + } + } + children +} + +/// What one directory sitting directly in `src/` is. +fn classified_directory(named: &str) -> MachineSourceChild { + let Some((number, rest)) = named.split_once('_') else { + return MachineSourceChild::Invalid(format!( + "`{MACHINE_DIRECTORY}/{named}/` carries no band number: a semantic home is `NN_name/`, \ + and a directory that is not one states no band at all, so the band order says nothing \ + about what it may import" + )); + }; + if number.len() != 2 || !number.chars().all(|digit| digit.is_ascii_digit()) { + return MachineSourceChild::Invalid(format!( + "`{MACHINE_DIRECTORY}/{named}/` opens with `{number}` where a semantic home's two-digit \ + band number belongs, so it names no band and stands in no order" + )); + } + if rest.is_empty() { + return MachineSourceChild::Invalid(format!( + "`{MACHINE_DIRECTORY}/{named}/` carries a band number and no name, so the coordinate it \ + occupies is about nothing" + )); + } + MachineSourceChild::NumberedHome(named.to_owned()) +} + +/// What one file sitting directly in `src/` is. +fn classified_root_file(named: &str) -> MachineSourceChild { + if named == CRATE_ROOT_FILE { + return MachineSourceChild::CrateRoot; + } + if RESERVED_ROOT_FILES.contains(&named) { + return MachineSourceChild::ReservedRootFile; + } + MachineSourceChild::Invalid(format!( + "`{MACHINE_DIRECTORY}/{named}` sits directly in the machine's source directory and is \ + neither the crate root nor one of the files the root reserves beside it ({}); a semantic \ + noun lives in its numbered home, and the root is never a shared-noun drawer", + RESERVED_ROOT_FILES.join(", ") + )) +} + +/// The band directories one crate root declares, in declaration order, with +/// every conditionally-declared band refused into the offences instead. +/// +/// Read off the `#[path = "…"]` attribute of each declared module, which is +/// what a band declaration IS. The directory is the path's own leading segment, +/// so the reading never has to be told how a band's `mod.rs` is spelled. +fn band_declarations(root: &syn::File, offenders: &mut Vec) -> Vec { + let mut declared = Vec::new(); + for item in &root.items { + match band_declaration(item) { + Some(BandDeclaration::Unconditional(directory)) => declared.push(directory), + Some(BandDeclaration::Conditional(directory)) => offenders.push(format!( + "lib.rs declares `{directory}` under a build condition: the band map states ONE \ + order, and a band compiled in some builds and not others stands in as many orders \ + as there are build populations — none of which this reading can establish" + )), + None => (), + } + } + declared +} + +/// How one band declaration stands in the order. +/// +/// Two states rather than a flag on the directory, because `clippy.toml` sets +/// `max-struct-bools = 0` and because a bare `true` says nothing about which way +/// the question was asked. +#[derive(Debug, PartialEq, Eq)] +enum BandDeclaration { + /// Declared unconditionally: the band stands in one order in every build. + Unconditional(String), + /// Declared under a build condition, so WHICH order it stands in depends on + /// which build is being asked about. + Conditional(String), +} + +/// The band directory one declared item names, where it names one. +fn band_declaration(item: &syn::Item) -> Option { + let syn::Item::Mod(module) = item else { + return None; + }; + let directory = module.attrs.iter().find_map(|attribute| { + let stated = string_attribute(attribute, PATH_ATTRIBUTE)?; + let (directory, _) = stated.split_once('/')?; + Some(directory.to_owned()) + })?; + if module.attrs.iter().any(is_conditional) { + Some(BandDeclaration::Conditional(directory)) + } else { + Some(BandDeclaration::Unconditional(directory)) + } +} + +/// Whether one attribute conditions the declaration it sits on. +/// +/// `cfg_attr` counts. It attaches an attribute — any attribute, `cfg` among them +/// — under a condition, so a declaration carrying one is conditioned just as +/// surely as one carrying `cfg` directly, and by a route this reader cannot +/// settle without deciding which build is meant. +fn is_conditional(attribute: &syn::Attribute) -> bool { + attribute.path().is_ident(CONDITION_ATTRIBUTE) + || attribute.path().is_ident(CONDITIONED_ATTRIBUTE) +} + +/// Whether one attribute is exactly the proof surface's own condition, +/// `#[cfg(test)]`. +/// +/// Exactly: the attribute is `cfg`, its body is one token, and that token is the +/// identifier `test`. `#[cfg(all(test, …))]`, `#[cfg(not(test))]` and +/// `#[cfg_attr(test, …)]` are none of them this, and each is refused for the +/// reason the whole rule exists — a module compiled in some builds and not +/// others stands in no single order. +fn is_the_proof_surface_condition(attribute: &syn::Attribute) -> bool { + let syn::Meta::List(ref stated) = attribute.meta else { + return false; + }; + if !stated.path.is_ident(CONDITION_ATTRIBUTE) { + return false; + } + let mut body = stated.tokens.clone().into_iter(); + match (body.next(), body.next()) { + (Some(TokenTree::Ident(ref word)), None) => word == PROOF_SURFACE_CONDITION, + (Some(_) | None, _) => false, + } +} + +/// The string one named attribute states, where it states one. +fn string_attribute(attribute: &syn::Attribute, named: &str) -> Option { + if !attribute.path().is_ident(named) { + return None; + } + let syn::Meta::NameValue(stated) = &attribute.meta else { + return None; + }; + let syn::Expr::Lit(literal) = &stated.value else { + return None; + }; + let syn::Lit::Str(written) = &literal.lit else { + return None; + }; + Some(written.value()) +} /// Declaration order IS the dependency order. /// @@ -90,13 +362,14 @@ const TOOLING_MODULE_ROOT: [&str; 3] = ["macros", "macroc", "src"]; /// /// # The dependency spellings this check recognizes /// -/// The reader is deliberately dumb, and its narrowness is part of the law it +/// The reading is deliberately narrow, and its narrowness is part of the law it /// states. It recognizes exactly these routes, and nothing else: /// -/// 1. `crate::name` — a plain path, in a `use` line, an inline expression, or a -/// rustdoc link. All three break when the named module moves. +/// 1. `crate::name` — a plain path, in a `use` item or in an expression. Both +/// break when the named module moves. /// 2. `crate::{a::…, b::…}` — a GROUPED use. Every segment head inside the -/// braces is read, so wrapping three imports in one `use` hides none of them. +/// braces is read, at any nesting, so wrapping three imports in one `use` +/// hides none of them. /// 3. `use crate::name as alias;` — an ALIASED import. The edge is read off the /// `crate::` path, so renaming the binding hides nothing. /// 4. `super::name` inside a SINGLE-FILE module — which is the crate root under @@ -105,6 +378,8 @@ const TOOLING_MODULE_ROOT: [&str; 3] = ["macros", "macroc", "src"]; /// RE-EXPORT route. Reaching a sibling's content through the crate root /// launders the edge: the reference names no owner, so nothing about the /// declaration order can be read off it. Owner paths only. +/// 6. A rustdoc intra-doc link naming `crate::…`, read out of the documentation +/// string the lexer hands back on the item it documents. /// /// A module is `name.rs` or the directory `name/`, and a directory module's /// edges are the union of every `.rs` file under it — a submodule reaching @@ -115,26 +390,50 @@ const TOOLING_MODULE_ROOT: [&str; 3] = ["macros", "macroc", "src"]; /// third crate. Those are outside this check, and this check does not pretend /// otherwise. /// -/// Test-only declarations are excluded: the proof surface (`laws`) is declared -/// `#[cfg(test)] mod laws;` precisely so it can look in every direction without -/// standing in the order it proves. -pub(crate) fn check_tooling_module_order(root: &Path) -> Result<(), String> { - let mut src = root.to_path_buf(); - for segment in TOOLING_MODULE_ROOT { - src.push(segment); - } - let lib_path = src.join("lib.rs"); - let lib = fs::read_to_string(&lib_path).map_err(|e| format!("{}: {e}", lib_path.display()))?; - let order = declared_module_order(&lib); +/// Exactly one build condition is admitted, and it is the proof surface's: +/// `#[cfg(test)] mod laws;` is declared that way precisely so it can look in +/// every direction without standing in the order it proves. Every OTHER +/// conditional declaration refuses. The reason is the law's own subject: this +/// reading establishes ONE declaration order, and a module compiled in some +/// builds and not others stands in as many orders as there are build +/// populations — `#[cfg(unix)] mod production_home;` would reach forward on one +/// platform and not on another, and neither answer is the order. A production +/// module belongs in the order unconditionally; the alternative is +/// unrepresentable rather than merely discouraged, because the reader refuses +/// the declaration rather than passing over it. +/// +/// Every direct child of the source directory is classified for the same reason +/// the machine's is: a module the crate root never declares stands in no order +/// at all, and a reader that only followed declarations would say nothing about +/// it. +pub(crate) fn check_tooling_module_order(snapshot: &RepositorySnapshot) -> Result<(), String> { + let root_path = format!("{TOOLING_SOURCE}/{CRATE_ROOT_FILE}"); + let root = snapshot + .rust() + .source(&CanonicalPath::spelled(&root_path)) + .taken(&root_path)?; + let DeclaredModules { + order, + proof_surface, + offences, + } = declared_modules(root); + let mut violations = offences; + for child in tooling_source_children(snapshot, &order, &proof_surface).values() { + if let ToolingSourceChild::Undeclared(ref reason) = *child { + violations.push(reason.clone()); + } + } if order.is_empty() { - return Err(format!("{} declares no modules", lib_path.display())); + violations.push(format!( + "{root_path} declares no module standing in the order" + )); } let mut modules = Vec::new(); for name in &order { - let (text, layout) = module_source(&src, name)?; - modules.push((name.clone(), text, layout)); + let (references, layout) = module_references(snapshot, name)?; + modules.push((name.clone(), references, layout)); } - let violations = module_order_violations(&order, &modules); + violations.extend(module_order_violations(&order, &modules)); if violations.is_empty() { Ok(()) } else { @@ -142,139 +441,353 @@ pub(crate) fn check_tooling_module_order(root: &Path) -> Result<(), String> { } } -/// The module names one `lib.rs` declares, in declaration order. +/// What one crate root's module declarations state. /// -/// Both `mod name;` and `pub mod name;` count — a private module participates -/// in the order exactly as a public one does. A declaration carrying -/// `#[cfg(test)]` on the line before it does not: the proof surface is outside -/// the order by construction. -fn declared_module_order(lib_text: &str) -> Vec { - let mut order = Vec::new(); - let mut test_only = false; - for raw in lib_text.lines() { - let line = raw.trim(); - if line.is_empty() || line.starts_with("//") { +/// Three lists rather than one, because a declaration is one of three things and +/// the reader that stood here collapsed two of them: it kept the unconditional +/// declarations and dropped every conditioned one into the same silence, +/// so `#[cfg(test)] mod laws;` and `#[cfg(unix)] mod production_home;` were +/// treated alike — the first correctly, the second by accident. +#[derive(Debug)] +struct DeclaredModules { + /// The names standing in the dependency order, in declaration order. + order: Vec, + /// The names declared under the proof surface's own condition. Outside the + /// order by construction, and still declared sources. + proof_surface: Vec, + /// Every declaration this law refuses outright, one line each. + offences: Vec, +} + +/// What one crate root declares, classified. +/// +/// Both `mod name;` and `pub mod name;` count — a private module participates in +/// the order exactly as a public one does. +fn declared_modules(root: &syn::File) -> DeclaredModules { + let mut declared = DeclaredModules { + order: Vec::new(), + proof_surface: Vec::new(), + offences: Vec::new(), + }; + for item in &root.items { + let syn::Item::Mod(module) = item else { continue; + }; + let named = module.ident.to_string(); + let conditions: Vec<&syn::Attribute> = module + .attrs + .iter() + .filter(|held| is_conditional(held)) + .collect(); + match *conditions.as_slice() { + [] => declared.order.push(named), + [only] if is_the_proof_surface_condition(only) => declared.proof_surface.push(named), + _ => declared.offences.push(format!( + "`{named}` is declared under a build condition other than the proof surface's \ + `#[cfg({PROOF_SURFACE_CONDITION})]`: this law reads ONE declaration order, and a \ + module compiled in some builds and not others stands in as many orders as there \ + are build populations, none of which this reading can establish. A production \ + module stands in the order unconditionally." + )), } - if line == "#[cfg(test)]" { - test_only = true; + } + declared +} + +/// What one direct child of the services' source directory is. +/// +/// Total over that directory for the reason the machine's classification is +/// total: declaration order IS this crate's dependency order, so a source the +/// crate root never declares stands in no order, and a reader following +/// declarations alone would never say so. +#[derive(Debug, PartialEq, Eq)] +enum ToolingSourceChild { + /// `lib.rs` — the crate root, which declares the order rather than standing + /// in it. + CrateRoot, + /// A module the crate root declares, written as `name.rs` or as `name/`. + DeclaredModule, + /// A module the crate root declares under the proof surface's condition. + ProofSurface, + /// A source no declaration accounts for, carried with the words the refusal + /// is written in. + Undeclared(String), +} + +/// Every direct child of the services' source directory, classified, keyed by +/// the child's own name. +fn tooling_source_children( + snapshot: &RepositorySnapshot, + order: &[String], + proof_surface: &[String], +) -> BTreeMap { + let mut children = BTreeMap::new(); + for (path, _) in snapshot.files().under(TOOLING_SOURCE) { + let Some(tail) = path.as_str().get(TOOLING_SOURCE.len().saturating_add(1)..) else { + children.insert( + path.to_string(), + ToolingSourceChild::Undeclared(format!( + "`{path}` sits beneath {TOOLING_SOURCE}/ and this reader cannot cut it into a \ + direct child, so which declared module owns it is unknown rather than nothing" + )), + ); continue; + }; + let child = match tail.split_once('/') { + Some((head, _)) => head, + None => tail, + }; + children.insert( + child.to_owned(), + classified_tooling_child(child, order, proof_surface), + ); + } + children +} + +/// What one direct child of the services' source directory is, by name. +fn classified_tooling_child( + child: &str, + order: &[String], + proof_surface: &[String], +) -> ToolingSourceChild { + if child == CRATE_ROOT_FILE { + return ToolingSourceChild::CrateRoot; + } + let named = match child.strip_suffix(SOURCE_SUFFIX) { + Some(stem) => stem, + None => child, + }; + if order.iter().any(|declared| declared == named) { + return ToolingSourceChild::DeclaredModule; + } + if proof_surface.iter().any(|declared| declared == named) { + return ToolingSourceChild::ProofSurface; + } + ToolingSourceChild::Undeclared(format!( + "`{TOOLING_SOURCE}/{child}` is a source no `mod` declaration in \ + {TOOLING_SOURCE}/{CRATE_ROOT_FILE} accounts for: declaration order IS this crate's \ + dependency order, so a module the crate root never declares stands in no order and this \ + law reads nothing about what it imports" + )) +} + +/// Every crate-root name one declared module reaches, and the layout it is in. +/// +/// This is the stage that turns a declared NAME into the edges an order is read +/// off. `name.rs` is its own source; `name/` is every `.rs` file under it, read +/// separately and unioned, because a submodule reaching forward is its parent +/// reaching forward. +fn module_references( + snapshot: &RepositorySnapshot, + name: &str, +) -> Result<(Vec, ModuleLayout), String> { + let flat = format!("{TOOLING_SOURCE}/{name}{SOURCE_SUFFIX}"); + let directory = format!("{TOOLING_SOURCE}/{name}"); + let carried: Vec<&CanonicalPath> = snapshot + .files() + .under(&directory) + .map(|(path, _)| path) + .filter(|path| path.extension_is("rs")) + .collect(); + match (snapshot.files().get(&flat), carried.as_slice()) { + (Some(_), []) => { + let text = snapshot.files().text(&flat).taken(&flat)?; + Ok((references_of(text, ModuleLayout::Flat)?, ModuleLayout::Flat)) } - let declaration = line.strip_prefix("pub ").unwrap_or(line); - if let Some(rest) = declaration.strip_prefix("mod ") - && let Some(name) = rest.strip_suffix(';') - && !name.contains(' ') - { - if !test_only { - order.push(name.to_string()); + (None, [_, ..]) => { + let mut found = Vec::new(); + for path in carried { + let text = snapshot.files().text(path.as_str()).taken(path.as_str())?; + found.extend(references_of(text, ModuleLayout::Directory)?); } - test_only = false; - continue; + Ok((found, ModuleLayout::Directory)) } - test_only = false; + // Both, which is one declaration written twice. The reader that stood + // here took the file and left every source under the directory unread, + // so a submodule reaching forward was invisible while the check kept + // reporting on the module it belongs to. + (Some(_), [_, ..]) => Err(format!( + "{name} is declared once and written twice — as {flat} and as {directory}/ — so which \ + sources carry its edges is a question two answers fit, and this law reads one" + )), + (None, []) => Err(format!( + "{name} is declared and is neither {flat} nor {directory}/" + )), } - order } -/// Every name one module's text reaches through a crate-root path, in the order -/// the text spells them, duplicates included. +/// Every crate-root name one source reaches. /// -/// Both openings are read: `crate::` and `super::`. In a single-file module the -/// two mean the same place, so a module reaching a sibling through `super::` has -/// taken exactly the edge `crate::` would have taken. +/// The source is LEXED rather than searched. `proc-macro2` owns Rust's token +/// grammar, so `crate` is an identifier here rather than a substring: a longer +/// name ending in `crate`, the word inside an ordinary comment, and the word +/// inside a string literal are all what they are, and none of them is a +/// reference. A documentation comment arrives as the string literal of a `doc` +/// attribute, which is where an intra-doc link lives, so those are read as text +/// on purpose — a rustdoc link IS prose naming a path. +fn references_of(text: &str, layout: ModuleLayout) -> Result, String> { + let tokens = TokenStream::from_str(text) + .map_err(|error| format!("the source does not lex as Rust: {error}"))?; + let mut found = Vec::new(); + read_references(tokens, layout, &mut found); + Ok(found) +} + +/// Whether one opening word names the crate root under this layout. /// -/// The keyword is matched whole, so a longer identifier ending in `crate` or -/// `super` is never mistaken for the crate root. A grouped use expands: every -/// segment head inside `{ … }` is read, at any nesting, so wrapping three -/// imports in one `use` hides none of them. +/// In a directory module, a submodule saying `super::` is naming its own +/// parent, which is not a forward reference at all; in a flat module, `super::` +/// and `crate::` name the same place, so both are read. +fn opens_the_crate_root(word: &str, layout: ModuleLayout) -> bool { + match layout { + ModuleLayout::Directory => word == "crate", + ModuleLayout::Flat => word == "crate" || word == "super", + } +} + +/// Walks one token stream, collecting every crate-root reference it spells. /// -/// Which openings are read is decided by the module's [`ModuleLayout`], which -/// the caller already holds — the layout was established when the module's text -/// was read, and is carried rather than guessed again here. -fn crate_references(module_text: &str, layout: ModuleLayout) -> Vec { - let openings: &[&str] = match layout { - ModuleLayout::Directory => &["crate::"], - ModuleLayout::Flat => &["crate::", "super::"], - }; - let mut found = Vec::new(); - for opening in openings.iter().copied() { - let mut from = 0usize; - while let Some(offset) = module_text.get(from..).and_then(|rest| rest.find(opening)) { - let start = from.saturating_add(offset); - let end = start.saturating_add(opening.len()); - let before_is_word = module_text - .get(..start) - .and_then(|head| head.chars().next_back()) - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_'); - if !before_is_word && let Some(tail) = module_text.get(end..) { - found.extend(referenced_heads(tail)); +/// A string LITERAL is a value rather than a path, so one is never read as a +/// reference — with exactly one exception, stated here and nowhere else: the +/// literal inside a `doc` attribute, which is where a rustdoc intra-doc link +/// lives. That is why documentation is reached at the attribute rather than by +/// scanning every literal the source happens to carry: a path written inside an +/// ordinary string is a string. +fn read_references(tokens: TokenStream, layout: ModuleLayout, into: &mut Vec) { + let trees: Vec = tokens.into_iter().collect(); + for (index, tree) in trees.iter().enumerate() { + match *tree { + TokenTree::Ident(ref word) => { + if opens_the_crate_root(&word.to_string(), layout) + && let Some(tail) = separator_follows(&trees, index) + { + into.extend(referenced_heads(tail)); + } } - from = end; + TokenTree::Group(ref group) => { + if group.delimiter() == Delimiter::Bracket && opens_documentation(group.stream()) { + into.extend(documentation_references(group.stream(), layout)); + continue; + } + read_references(group.stream(), layout, into); + } + TokenTree::Literal(_) | TokenTree::Punct(_) => (), } } - found +} + +/// Whether one attribute's body is a `doc` attribute — which is what a +/// documentation comment arrives as. +fn opens_documentation(tokens: TokenStream) -> bool { + matches!(tokens.into_iter().next(), Some(TokenTree::Ident(ref word)) if word == "doc") +} + +/// Every crate-root name one documentation attribute's own literals name. +fn documentation_references(tokens: TokenStream, layout: ModuleLayout) -> Vec { + tokens + .into_iter() + .filter_map(|written| match written { + TokenTree::Literal(documented) => Some(documented.to_string()), + TokenTree::Group(_) | TokenTree::Ident(_) | TokenTree::Punct(_) => None, + }) + .flat_map(|documented| documented_references(&documented, layout)) + .collect() +} + +/// The tree after a `::` separator following the token at `index`, where one +/// follows. +fn separator_follows(trees: &[TokenTree], index: usize) -> Option<&TokenTree> { + let first = trees.get(index.saturating_add(1))?; + let second = trees.get(index.saturating_add(2))?; + let TokenTree::Punct(ref opening) = *first else { + return None; + }; + let TokenTree::Punct(ref closing) = *second else { + return None; + }; + if opening.as_char() != ':' || closing.as_char() != ':' { + return None; + } + trees.get(index.saturating_add(3)) } /// The segment heads one crate-root path reaches: the single name of a plain -/// path, or every head inside a grouped use. -fn referenced_heads(tail: &str) -> Vec { - let trimmed = tail.trim_start(); - if !trimmed.starts_with('{') { - let name: String = trimmed - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - return if name.is_empty() { - Vec::new() - } else { - vec![name] - }; +/// path, or every head inside a grouped use, at any nesting. +fn referenced_heads(tail: &TokenTree) -> Vec { + match *tail { + TokenTree::Ident(ref named) => vec![named.to_string()], + TokenTree::Group(ref group) if group.delimiter() == Delimiter::Brace => { + grouped_heads(group.stream()) + } + TokenTree::Group(_) | TokenTree::Punct(_) | TokenTree::Literal(_) => Vec::new(), } +} + +/// Every head inside one grouped use, at any nesting. +fn grouped_heads(tokens: TokenStream) -> Vec { let mut heads = Vec::new(); - let mut depth = 0usize; - let mut current = String::new(); - let mut at_head = false; - for character in trimmed.chars() { - match character { - '{' => { - depth = depth.saturating_add(1); - at_head = true; - current.clear(); - } - '}' => { - if !current.is_empty() { - heads.push(std::mem::take(&mut current)); - } - depth = depth.saturating_sub(1); - if depth == 0 { - break; + let mut at_head = true; + for tree in tokens { + match tree { + TokenTree::Punct(ref mark) if mark.as_char() == ',' => at_head = true, + TokenTree::Ident(ref named) => { + if at_head { + heads.push(named.to_string()); + at_head = false; } } - ',' => { - if !current.is_empty() { - heads.push(std::mem::take(&mut current)); - } - at_head = true; - } - ':' => { - if !current.is_empty() { - heads.push(std::mem::take(&mut current)); - } - at_head = false; - } - _ if character.is_whitespace() => {} - _ if at_head && (character.is_ascii_alphanumeric() || character == '_') => { - current.push(character); - } - _ => { - current.clear(); + TokenTree::Group(ref group) if group.delimiter() == Delimiter::Brace => { + heads.extend(grouped_heads(group.stream())); at_head = false; } + TokenTree::Group(_) | TokenTree::Literal(_) | TokenTree::Punct(_) => (), } } heads } +/// Every crate-root name one documentation string names. +/// +/// A rustdoc intra-doc link is prose carrying a path, which is why this half is +/// read as text — and it is the ONE half that is, stated here rather than left +/// as the case somebody notices later. +fn documented_references(written: &str, layout: ModuleLayout) -> Vec { + let mut found = Vec::new(); + for opening in ["crate", "super"] { + if !opens_the_crate_root(opening, layout) { + continue; + } + let needle = format!("{opening}::"); + let mut from = 0usize; + while let Some(offset) = written.get(from..).and_then(|rest| rest.find(&needle)) { + let start = from.saturating_add(offset); + let end = start.saturating_add(needle.len()); + let before_is_word = written + .get(..start) + .and_then(|head| head.chars().next_back()) + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_'); + if !before_is_word + && let Some(tail) = written.get(end..) + && let Some(name) = leading_name(tail) + { + found.push(name); + } + from = end; + } + } + found +} + +/// The identifier one text opens with, where it opens with one. +fn leading_name(tail: &str) -> Option { + let name: String = tail + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if name.is_empty() { None } else { Some(name) } +} + /// Every unlawful edge in one module set, one description per edge. /// /// Two kinds are refused, and they are different findings: @@ -291,20 +804,20 @@ fn referenced_heads(tail: &str) -> Vec { /// edge. fn module_order_violations( order: &[String], - modules: &[(String, String, ModuleLayout)], + modules: &[(String, Vec, ModuleLayout)], ) -> Vec { let position = |name: &str| order.iter().position(|declared| declared == name); let mut violations = Vec::new(); - for (name, text, layout) in modules { + for (name, references, _) in modules { let Some(here) = position(name) else { continue; }; let mut reported: Vec = Vec::new(); - for referenced in crate_references(text, *layout) { - if reported.contains(&referenced) { + for referenced in references { + if reported.contains(referenced) { continue; } - match position(&referenced) { + match position(referenced) { Some(there) if there > here => { reported.push(referenced.clone()); violations.push(format!( @@ -328,69 +841,127 @@ fn module_order_violations( /// Planted reversals for both orders. /// -/// The module-order law is pure over `(order, module sources)`, so its +/// The module-order law is pure over `(order, module references)`, so its /// reversals are synthetic module sets held in memory. The band map reads a -/// directory, so its reversal is planted against a scratch root outside the +/// tree, so its reversal is planted against a scratch root outside the /// repository. Neither writes inside the tree it guards. #[cfg(test)] mod tests { use super::{ - TOOLING_MODULE_ROOT, check_band_map, declared_module_order, module_order_violations, + HOME_FILES, RESERVED_ROOT_FILES, check_band_map, check_tooling_module_order, + declared_modules, module_order_violations, references_of, }; use crate::checks::scratch::Scratch; + use crate::repository::snapshot::repository_snapshot; use crate::repository::types::ModuleLayout; - use crate::repository::walk::{module_source, repo_root}; - use std::fs; - use std::path::PathBuf; /// One synthetic module set, as `(name, source text)` pairs. Every synthetic /// module is flat: the directory layout is exercised against the real tree, /// where the directory exists. - fn sources(pairs: &[(&str, &str)]) -> Vec<(String, String, ModuleLayout)> { - pairs - .iter() - .map(|(name, text)| ((*name).to_string(), (*text).to_string(), ModuleLayout::Flat)) - .collect() + fn sources(pairs: &[(&str, &str)]) -> Result, ModuleLayout)>, String> { + let mut read = Vec::new(); + for (name, text) in pairs { + read.push(( + (*name).to_owned(), + references_of(text, ModuleLayout::Flat)?, + ModuleLayout::Flat, + )); + } + Ok(read) + } + + /// One crate root, parsed. + fn root(text: &str) -> Result { + syn::parse_file(text).map_err(|error| error.to_string()) } - /// The declaration order is read out of the file in file order, not sorted, - /// and the test-only proof surface is excluded from it. + /// The declaration order is read out of the ITEMS in file order, not + /// sorted, and the test-only proof surface is excluded from it. + /// + /// Planted reversal for the line reader this replaced: the last declaration + /// here is written across two lines, which no reader whose subject is a + /// trimmed line can see. #[test] - fn the_declaration_order_is_file_order_without_the_proof_surface() { + fn the_declaration_order_is_item_order_without_the_proof_surface() -> Result<(), String> { let lib = "//! doc\n\npub mod plane;\n\n/// note\npub mod refusal;\n\nmod helper;\n\n\ - #[cfg(test)]\nmod laws;\n"; + #[cfg(test)]\nmod laws;\n\nmod\n wrapped;\n"; + let declared = declared_modules(&root(lib)?); assert_eq!( - declared_module_order(lib), + declared.order, vec![ String::from("plane"), String::from("refusal"), - String::from("helper") + String::from("helper"), + String::from("wrapped"), ] ); + assert_eq!(declared.proof_surface, vec![String::from("laws")]); + assert!(declared.offences.is_empty(), "{:?}", declared.offences); + Ok(()) + } + + /// Planted reversal: a module declared under a build condition that is NOT + /// the proof surface's. + /// + /// The exclusion was written for `#[cfg(test)] mod laws;` and stated as "a + /// declaration carrying a build condition", so it also let + /// `#[cfg(unix)] mod production_home;` out of the order entirely: that module + /// could reach forward on one platform and the law would report nothing, + /// because it had never been in the population. The exclusion is now the + /// exact condition it was written for, and every other conditioned + /// declaration refuses — including the ones that CONTAIN the proof surface's + /// condition without being it, and the one that attaches a condition + /// indirectly. + #[test] + fn a_module_conditioned_on_anything_but_the_proof_surface_is_a_violation() -> Result<(), String> + { + for condition in [ + "#[cfg(unix)]", + "#[cfg(all(test, feature = \"extra\"))]", + "#[cfg(not(test))]", + "#[cfg_attr(test, path = \"elsewhere.rs\")]", + "#[cfg(test)]\n#[cfg(unix)]", + ] { + let lib = format!("pub mod plane;\n\n{condition}\nmod production_home;\n"); + let declared = declared_modules(&root(&lib)?); + assert_eq!(declared.order, vec![String::from("plane")], "{condition}"); + assert!(declared.proof_surface.is_empty(), "{condition}"); + assert!( + declared + .offences + .iter() + .any(|offence| offence.contains("production_home") + && offence.contains("build populations")), + "{condition} -> {:?}", + declared.offences + ); + } + Ok(()) } /// Planted reversal: a module reaching FORWARD to a module declared after /// it — the shape every cycle contains at least one of. #[test] - fn a_forward_reference_is_a_violation() { + fn a_forward_reference_is_a_violation() -> Result<(), String> { let order = vec![String::from("plane"), String::from("planning")]; let found = module_order_violations( &order, &sources(&[ ("plane", "use crate::planning::ProjectionPlan;\n"), ("planning", "use crate::plane::ExactIdentity;\n"), - ]), + ])?, ); assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("declared later"))); assert!(found.iter().any(|v| v.contains("`plane`"))); + Ok(()) } /// Planted reversal: the exact cycle this discipline was written to kill — /// two modules importing each other. Whichever way the pair is declared, /// one of the two edges points forward. #[test] - fn a_two_module_cycle_is_a_violation() { + fn a_two_module_cycle_is_a_violation() -> Result<(), String> { let order = vec![ String::from("planning"), String::from("explanation_protocol"), @@ -406,33 +977,41 @@ mod tests { "explanation_protocol", "use crate::planning::ProjectionPlan;\n", ), - ]), + ])?, ); assert_eq!(found.len(), 1, "{found:?}"); assert!(found.iter().any(|v| v.contains("`planning`"))); + Ok(()) } /// Planted reversal: the forward reference spelled inline rather than in a - /// `use` line, which no scan of import lines alone would see. + /// `use` line, inside a grouped use, and inside a rustdoc link — three + /// places no scan of import lines alone would see. #[test] - fn an_inline_forward_path_is_a_violation() { + fn a_forward_reference_hides_in_none_of_its_spellings() -> Result<(), String> { let order = vec![String::from("plane"), String::from("diagnostics")]; - let found = module_order_violations( - &order, - &sources(&[( - "plane", - "fn f() { let _ = crate::diagnostics::MacrocPhase::Capture; }\n", - )]), - ); - assert_eq!(found.len(), 1, "{found:?}"); + for spelling in [ + "fn f() { let _ = crate::diagnostics::MacrocPhase::Capture; }\n", + "use crate::{plane::Own, diagnostics::MacrocPhase};\n", + "/// See [`crate::diagnostics::MacrocPhase`] for the phases.\npub fn f() {}\n", + "use crate::diagnostics::MacrocPhase as Phase;\n", + ] { + let found = module_order_violations(&order, &sources(&[("plane", spelling)])?); + assert_eq!(found.len(), 1, "{spelling} -> {found:?}"); + } + Ok(()) } /// The positive control: a clean set passes. Backward references, repeated /// references, a module naming itself, and a longer identifier merely /// ENDING in `crate` are all lawful, so the check reports something real /// rather than everything. + /// + /// The lexer is what makes the last three of these free: `othercrate` is one + /// identifier, a comment is not a token at all, and a path inside a string + /// literal is a string. #[test] - fn a_clean_module_set_passes() { + fn a_clean_module_set_passes() -> Result<(), String> { let order = vec![ String::from("plane"), String::from("refusal"), @@ -449,33 +1028,31 @@ mod tests { ( "planning", "use crate::plane::OwnerFactRef;\nuse crate::refusal::PlanSeat;\n\ - // othercrate::planning is not this crate\n\ - fn f() { crate::planning::own(); }\n", + use othercrate::planning::Nothing;\n\ + // crate::diagnostics in a comment is not an edge\n\ + fn f() -> &'static str { \"crate::diagnostics\" }\n\ + fn g() { crate::planning::own(); }\n", ), - ]), + ])?, ); assert!(found.is_empty(), "{found:?}"); + Ok(()) } /// The real services tree holds: declaration order IS its dependency order. #[test] fn the_real_services_modules_are_in_dependency_order() -> Result<(), String> { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let mut src = root; - for segment in TOOLING_MODULE_ROOT { - src.push(segment); - } - let lib = fs::read_to_string(src.join("lib.rs")).unwrap_or_default(); - let order = declared_module_order(&lib); - assert!(order.len() > 1, "services lib.rs declares {order:?}"); - let mut modules = Vec::new(); - for name in &order { - let (text, layout) = module_source(&src, name)?; - assert!(!text.is_empty(), "{name} is unreadable"); - modules.push((name.clone(), text, layout)); - } - let found = module_order_violations(&order, &modules); - assert!(found.is_empty(), "{found:?}"); + let found = check_tooling_module_order(repository_snapshot()?); + assert!(found.is_ok(), "{found:?}"); + Ok(()) + } + + /// The real machine tree holds: every band is complete and declared in band + /// order. + #[test] + fn the_real_band_map_matches_lib() -> Result<(), String> { + let found = check_band_map(repository_snapshot()?); + assert!(found.is_ok(), "{found:?}"); Ok(()) } @@ -483,7 +1060,7 @@ mod tests { /// and declarations out of band order. Three ways the band map and the /// crate drift apart, and the third is the one no file listing would catch. #[test] - fn a_band_map_that_drifts_from_lib_is_a_violation() { + fn a_band_map_that_drifts_from_lib_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("band-map"); let ordered = "#[path = \"00_refusal/mod.rs\"]\npub mod refusal;\n\ #[path = \"01_logic/mod.rs\"]\npub mod logic;\n"; @@ -493,10 +1070,10 @@ mod tests { } } scratch.write("src/lib.rs", ordered); - assert!(check_band_map(scratch.root()).is_ok()); + assert!(check_band_map(&scratch.read()?).is_ok()); - let _removed = fs::remove_file(scratch.root().join("src/01_logic/types.rs")); - let incomplete = check_band_map(scratch.root()); + scratch.remove("src/01_logic/types.rs"); + let incomplete = check_band_map(&scratch.read()?); assert!(incomplete.is_err_and(|reason| reason.contains("01_logic missing types.rs"))); scratch.write("src/01_logic/types.rs", "the home's content\n"); @@ -504,7 +1081,7 @@ mod tests { "src/lib.rs", "#[path = \"00_refusal/mod.rs\"]\npub mod refusal;\n", ); - let undeclared = check_band_map(scratch.root()); + let undeclared = check_band_map(&scratch.read()?); assert!(undeclared.is_err_and(|reason| reason.contains("does not declare 01_logic"))); scratch.write( @@ -512,7 +1089,151 @@ mod tests { "#[path = \"01_logic/mod.rs\"]\npub mod logic;\n\ #[path = \"00_refusal/mod.rs\"]\npub mod refusal;\n", ); - let reordered = check_band_map(scratch.root()); + let reordered = check_band_map(&scratch.read()?); assert!(reordered.is_err_and(|reason| reason.contains("out of band order"))); + Ok(()) + } + + /// The band declaration is read off the ATTRIBUTE rather than off a + /// literal spelling of it. + /// + /// Planted reversal for the substring reader this replaced, which looked for + /// `#[path = "00_refusal/mod.rs"]` exactly. Written with different spacing + /// the attribute states the same declaration and that reader reported a band + /// `lib.rs` "does not declare" — a law refusing a lawful crate over + /// whitespace. + #[test] + fn a_band_declaration_is_read_however_it_is_spaced() -> Result<(), String> { + let scratch = Scratch::named("band-map-spacing"); + for file in ["README.md", "mod.rs", "types.rs"] { + scratch.write(&format!("src/00_refusal/{file}"), "the home's content\n"); + } + scratch.write( + "src/lib.rs", + "#[path=\"00_refusal/mod.rs\"]\npub mod refusal;\n", + ); + let found = check_band_map(&scratch.read()?); + assert!(found.is_ok(), "{found:?}"); + Ok(()) + } + + /// Planted reversal: an entry under `src/` that is no semantic home. + /// + /// Four spellings, and the reader that stood here passed silently over every + /// one of them, because it RECOGNIZED a band and let everything else fall + /// through a `continue`. A misspelled architectural directory left the band + /// population with nothing said anywhere, and the check that reports on the + /// band map kept printing PASS about a tree with an unclassified directory + /// in it. + #[test] + fn an_entry_under_src_that_is_no_home_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("src-population"); + scratch.write( + "src/lib.rs", + "#[path = \"00_refusal/mod.rs\"]\npub mod refusal;\n", + ); + for file in HOME_FILES { + scratch.write(&format!("src/00_refusal/{file}"), "the home's content\n"); + } + for reserved in RESERVED_ROOT_FILES { + scratch.write(&format!("src/{reserved}"), "the root's own file\n"); + } + assert!(check_band_map(&scratch.read()?).is_ok()); + + for (planted, said) in [ + ("src/bounds/mod.rs", "carries no band number"), + ("src/5_bounds/mod.rs", "opens with `5`"), + ("src/05_/mod.rs", "band number and no name"), + ("src/notes.md", "sits directly in"), + ] { + scratch.write(planted, "an entry the population never classified\n"); + let found = check_band_map(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains(said)), + "{planted} left the population in silence" + ); + scratch.remove(planted); + assert!(check_band_map(&scratch.read()?).is_ok(), "{planted}"); + } + Ok(()) + } + + /// Planted reversal: a band declared under a build CONDITION. + /// + /// The band map states one order. A band compiled in some builds and not + /// others stands in as many orders as there are build populations, and the + /// reader that read `#[path]` alone accepted the declaration as though it + /// stood in all of them. + #[test] + fn a_conditionally_declared_band_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("band-map-condition"); + for file in HOME_FILES { + scratch.write(&format!("src/00_refusal/{file}"), "the home's content\n"); + } + scratch.write( + "src/lib.rs", + "#[cfg(unix)]\n#[path = \"00_refusal/mod.rs\"]\npub mod refusal;\n", + ); + let found = check_band_map(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("under a build condition")), + "a band declared for one platform passed as a band declared for the crate" + ); + Ok(()) + } + + /// Planted reversal: a source in the services' own directory that no `mod` + /// declaration accounts for. + /// + /// Declaration order IS this crate's dependency order, so a module the crate + /// root never declares stands in no order at all — and a reader that walked + /// the declarations never had a word to say about it. + #[test] + fn a_services_source_no_declaration_accounts_for_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("tooling-population"); + scratch.write( + "macros/macroc/src/lib.rs", + "pub mod plane;\n\n#[cfg(test)]\nmod laws;\n", + ); + scratch.write("macros/macroc/src/plane.rs", "//! no edges at all\n"); + scratch.write("macros/macroc/src/laws.rs", "//! the proof surface\n"); + assert!(check_tooling_module_order(&scratch.read()?).is_ok()); + + scratch.write("macros/macroc/src/orphan.rs", "//! nobody declares this\n"); + let found = check_tooling_module_order(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("orphan.rs")), + "a source outside every declaration stood outside the law as well" + ); + Ok(()) + } + + /// Planted reversal: one declared module written BOTH as a file and as a + /// directory. + /// + /// The reader took the file and returned, leaving every source under the + /// directory unread — so a submodule reaching forward was invisible while + /// the check went on reporting about the module that owns it. + #[test] + fn a_module_written_as_both_a_file_and_a_directory_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("tooling-layout"); + scratch.write( + "macros/macroc/src/lib.rs", + "pub mod plane;\npub mod token;\n", + ); + scratch.write("macros/macroc/src/plane.rs", "//! no edges at all\n"); + scratch.write("macros/macroc/src/token.rs", "use crate::plane::Own;\n"); + assert!(check_tooling_module_order(&scratch.read()?).is_ok()); + + scratch.write( + "macros/macroc/src/plane/inner.rs", + "use crate::token::Reaching;\n", + ); + let found = check_tooling_module_order(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("written twice")), + "one declaration written two ways was read one way and passed" + ); + Ok(()) } } diff --git a/xtask/src/checks/positivity.rs b/xtask/src/checks/positivity.rs new file mode 100644 index 0000000..d6495d3 --- /dev/null +++ b/xtask/src/checks/positivity.rs @@ -0,0 +1,928 @@ +//! The positivity ladder's derived population: a family that bounds an +//! inhabitant-promising seat and declares no compile-time magnitude is on the +//! runtime ladder. +//! +//! # The two ladders, and why only one of them needs a law +//! +//! `crate::types::Limit` families reach a capacity by one of two roads. A +//! DECLARED magnitude is a number in the source: `ConstLimit` carries it, a +//! `const` block stands it under a plane's ceiling, and `PositiveLimit` proves it +//! admits an item — all before the program runs, all by the compiler. An +//! EVIDENCE-SELECTED magnitude does not exist until the owner's evidence selects +//! it, so no `const` block can see it and the same two facts have to be +//! established by VALUES instead: `LimitWitness` carries the selection and +//! `PositiveLimitWitness` carries the promise that it admits an item. +//! +//! `PositiveLimitWitness::inhabited` is bounded on `EvidenceSelectedLimit`, so a +//! family that never declared the runtime ladder has no road to a runtime +//! capacity at all, and the compile-fail fixtures name that refusal from outside +//! the crate. That half is `rustc`'s and this law does not restate it. +//! +//! What `rustc` cannot say is the UNIVERSAL sentence: that every family which +//! needs the runtime ladder is on it. Rust cannot enumerate the types +//! implementing a trait, so a law naming the families would be a list somebody +//! maintains — the hand-maintained inventory this repository bans. The sentence +//! was therefore deliberately never written when the witness landed, and the +//! population it would have been about was named in prose and counted by nothing. +//! +//! # The population, DERIVED +//! +//! Three facts about the machine's own sources decide membership, and every one +//! of them is read off a parse: +//! +//! 1. the source declares `impl … Limit for F` — F is a limit family; +//! 2. no source declares `impl … ConstLimit for F` — F declares no +//! compile-time magnitude, so the declared road is closed to it; +//! 3. F is the LAST type argument of a `NonEmptyBounded<…>` or an +//! `AdmittedPrefix<…>` written in a DECLARATION — a field, a signature, an +//! associated type, an alias. Both of those promise an inhabitant: +//! `NonEmptyBounded` is structurally non-empty and `AdmittedPrefix` carries +//! one. +//! +//! A family satisfying all three is in the DENOMINATOR: it bounds a seat that +//! promises an inhabitant, and the only road to that promise is the runtime +//! ladder. It is in the NUMERATOR — witnessed — when a source declares +//! `impl … EvidenceSelectedLimit for F`, which is the one bound under which a +//! `PositiveLimitWitness` for it can be minted at all. Both numbers are printed +//! on every run, because a population that quietly shrank would otherwise keep +//! this law passing while it guarded less. +//! +//! # What a DECLARATION is here, and why bodies are not read +//! +//! A seat is a declaration: what a record carries, what a road takes, what a road +//! hands back. A `let` binding inside a function body is not a seat — its type +//! came from a seat somewhere else, and reading it would count the same family +//! twice while adding a population no owner declared. So this reader descends +//! through items, fields, signatures, aliases, associated constants and +//! associated types, and stops at the first `{` of a body. +//! +//! # Its stated ceilings, said out loud +//! +//! **The services are outside the population, and not because they are +//! uninteresting.** `macros/macroc` declares every one of its limit families +//! through a `macro_rules!` transcriber — one row per family, expanding to +//! `impl Limit` and `impl ConstLimit` together. A reader that resolves no macro +//! sees no `ConstLimit` there, so every services family would read as declaring +//! no compile-time magnitude and this law would refuse all of them falsely. The +//! honest scope is the sources whose declarations are written where they are +//! read, and that is the machine's own. +//! +//! **The proof surface is outside it, for the reason `coupling` excludes it.** +//! `src/laws.rs` declares demonstration families whose whole existence is to give +//! the admission algebra something to refuse; counting them would put a fixture +//! in a denominator about the machine. +//! +//! **It resolves nothing.** A path is read by its LAST SEGMENT, so +//! `crate::types::Limit` and `Limit` are one trait here and a type alias to +//! either is neither. It evaluates no `cfg`: a declaration written under one is +//! read as declared. It expands no macro, so a family or a seat assembled by one +//! is outside this law — and this law does not pretend otherwise. +//! +//! **It does not claim a witness is ever minted.** That a family is ON the +//! ladder is what is read here; whether any runtime road ever selects a +//! magnitude for it is a behavioural fact no parse reaches, and the machine +//! carries no runtime yet. +//! +//! **It no longer judges a family declaring BOTH ladders, and no longer needs +//! to.** Such a family stated two authorities for one capacity; `crate::types` +//! once named it a declaration defect no bound could see, and this leg carried +//! the matching nonclaim. `Limit::Authority` is what sees it now — one +//! associated type resolves to one type, `ConstLimit` and `EvidenceSelectedLimit` +//! name theirs exactly, and the second declaration is a type mismatch. A parse +//! that re-derived the same refusal would be the weaker restatement this +//! repository drains, so what stands here is only the population question +//! `rustc` cannot answer. +//! +//! # Two homes may not spell one family the same, and that is REFUSED +//! +//! A family is keyed by its TERMINAL name, and it has to be: a seat names its +//! bound as `NonEmptyBounded`, with no home in the spelling +//! and no resolver here to supply one. So a name declared by two homes has one +//! record on the family side and an unattributable bound on the seat side, and +//! merging them would fold one home's ladders onto the other home's seats — +//! silently, and in the direction that makes the offence disappear. That is the +//! alias-collision defect this repository has already paid for once, and +//! reproducing it inside the law that replaced part of that machinery is not a +//! trade worth making. +//! +//! So a terminal name declared at more than one site REFUSES. It is not +//! qualified and not disambiguated: the seat side carries no owner to qualify +//! against, so the honest move is the loud one until a generated declaration +//! contract supplies a stable owner-qualified identity that BOTH sides carry. A +//! refused name is also out of the printed population, because a name with two +//! declarations names no family whose ladder could be counted; the refusal is +//! what stands in its place. +//! +//! A site is a file plus the inline module chain the declaration sits in, so two +//! declarations in one file's separate `mod` blocks are two sites and are +//! refused exactly as two files would be. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::repository::snapshot::{MACHINE_DIRECTORY, RepositorySnapshot}; +use crate::repository::types::CanonicalPath; + +/// The proof surface, excluded from the population by name. +const PROOF_SURFACE: &str = "src/laws.rs"; + +/// The contract every limit family implements. +const LIMIT_CONTRACT: &str = "Limit"; + +/// The contract a family declaring a compile-time magnitude implements. +const DECLARED_LADDER: &str = "ConstLimit"; + +/// The contract a family declaring an evidence-selected magnitude implements — +/// the one bound `PositiveLimitWitness`'s mint stands on. +const RUNTIME_LADDER: &str = "EvidenceSelectedLimit"; + +/// The seats that promise an inhabitant: one structurally non-empty collection, +/// and the report package that carries one. +const INHABITANT_PROMISING_SEATS: [&str; 2] = ["NonEmptyBounded", "AdmittedPrefix"]; + +/// Every limit family bounding an inhabitant-promising seat in the machine, with +/// no compile-time magnitude declared for it, declares the runtime ladder — and +/// no two homes spell one family the same, because a population that cannot say +/// which family a seat named has no denominator to count. +/// +/// # Errors +/// +/// Returns the offences one line at a time, and returns a read failure as +/// itself: a gate that cannot read its subject says so rather than reporting an +/// empty population. +pub(crate) fn check_inhabitant_promising_limits( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { + let sources = positivity_sources(snapshot)?; + let verdict = positivity_verdict(&sources); + + // The denominator is DERIVED and printed on every run, because a population + // that quietly shrank would otherwise keep this check passing while it + // guarded less. + println!( + "inhabitant-promising limits: {} witnessed / {} declared", + verdict.witnessed, verdict.declared + ); + if verdict.declared == 0 { + return Err(String::from( + "no limit family was found bounding an inhabitant-promising seat without a \ + compile-time magnitude: this denominator cannot be empty while the runtime ladder \ + exists, so the reader is looking at the wrong tree", + )); + } + if verdict.offenders.is_empty() { + Ok(()) + } else { + Err(verdict.offenders.join("; ")) + } +} + +/// What the positivity leg counted, and what it refuses. +#[derive(Debug)] +struct PositivityVerdict { + /// Families bounding an inhabitant-promising seat with no compile-time + /// magnitude declared for them. + declared: usize, + /// Those of them declaring the runtime ladder. + witnessed: usize, + /// Every offence, one line each. + offenders: Vec, +} + +/// What the sources declare about one limit family. +/// +/// The ladders are carried as the SET OF CONTRACTS a source declares for the +/// family rather than as two flags, which is what `clippy.toml`'s +/// `max-struct-bools = 0` asks for and is also the honest shape: a pair of +/// booleans would have read as a state machine nobody named. A source claiming +/// both ladders is still representable in this READING even though it no longer +/// compiles, and that is deliberate — a reader whose shape could not hold what a +/// fixture can write would be judging a tree it had already decided about. +#[derive(Debug, Default)] +struct FamilyFacts { + /// EVERY site where `impl … Limit for F` was read, not the first. + /// + /// A set rather than one site, because one terminal name read at two sites + /// is the collision this leg refuses, and a reader that kept only the first + /// would have thrown the evidence away before the question was asked. + declared_at: BTreeSet, + /// Every ladder contract a source declares for it, by name. + ladders: BTreeSet, +} + +/// Everything one pass over the sources read. +#[derive(Debug, Default)] +struct Reading { + /// Every limit family the machine declares, by name. + families: BTreeMap, + /// Every family bounding an inhabitant-promising seat, and the first site + /// that seats it. + seated: BTreeMap, +} + +/// Derives the population out of parsed trees and judges each family in it. +/// +/// Pure over its inputs — `(canonical path, parsed tree)` pairs handed over by +/// the snapshot — so the reversals below are planted in memory and the law that +/// guards the ladder is never proven by editing a home. A source that did not +/// parse never reaches here: the snapshot carries it as unread, and the caller +/// refuses the whole reading rather than deriving a population one file short. +fn positivity_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> PositivityVerdict { + let reading = read_sources(sources); + let mut verdict = PositivityVerdict { + declared: 0, + witnessed: 0, + offenders: Vec::new(), + }; + for (family, facts) in &reading.families { + // Two homes spelling one family the same. The seat side carries the + // terminal name alone, so there is nothing here to tell the two apart + // and no honest way to attribute a bound to either: the name is refused + // and left out of the population rather than merged into one record. + if facts.declared_at.len() > 1 { + let sites: Vec<&str> = facts.declared_at.iter().map(String::as_str).collect(); + verdict.offenders.push(format!( + "`{family}` is declared as a limit family at {} sites — {} — and a seat names its \ + bound by that terminal name alone, so no reading here can tell which family a \ + `NonEmptyBounded<_, {family}>` is bounded by; one of them takes a name of its \ + own, because merging two families under one name is the silent failure this \ + population exists to prevent", + sites.len(), + sites.join(" and ") + )); + continue; + } + // No `impl … Limit for F` was read, so nothing here established that + // this name is a limit family at all. Unreachable in code that + // compiles — both ladders have `Limit` as their supertrait — and + // stated rather than assumed, because a fixture can write it. + let Some(declared_at) = facts.declared_at.first() else { + continue; + }; + if facts.ladders.contains(DECLARED_LADDER) { + continue; + } + let Some(seat) = reading.seated.get(family) else { + continue; + }; + verdict.declared = verdict.declared.saturating_add(1); + if facts.ladders.contains(RUNTIME_LADDER) { + verdict.witnessed = verdict.witnessed.saturating_add(1); + continue; + } + verdict.offenders.push(format!( + "{seat}: `{family}` bounds a seat that promises an inhabitant and declares no \ + compile-time magnitude, and {declared_at} puts it on neither ladder; the only road to \ + a capacity for it is `{RUNTIME_LADDER}`, so no `PositiveLimitWitness<{family}>` can \ + be minted and the seat promises what nothing can establish" + )); + } + verdict +} + +/// Reads the family declarations and the seats out of the parsed trees. +fn read_sources(sources: &[(&CanonicalPath, &syn::File)]) -> Reading { + let mut reading = Reading::default(); + for (path, file) in sources { + read_items(path.as_str(), &file.items, &mut reading); + } + reading +} + +/// Reads one module's items, then every inline module inside it. +/// +/// The `path` a declaration is recorded at is its SITE: the file, and the inline +/// module chain the declaration sits in, appended one `::` segment per descent. +/// That is what makes two declarations of one terminal name in one file's +/// separate `mod` blocks two sites and therefore a refused collision, rather +/// than one record that quietly absorbed the second. +/// +/// Written as an `if let` chain rather than a match because `syn::Item` is +/// `non_exhaustive`: the items this reading has a question about are named, and +/// every other item is passed over without a wildcard arm standing in for a set +/// no crate outside `syn` can enumerate. +fn read_items(path: &str, items: &[syn::Item], reading: &mut Reading) { + for item in items { + if let syn::Item::Impl(declared) = item { + read_implementation(path, declared, reading); + } else if let syn::Item::Struct(declared) = item { + read_fields(path, &declared.fields, reading); + } else if let syn::Item::Union(declared) = item { + for field in &declared.fields.named { + read_seat(path, &field.ty, reading); + } + } else if let syn::Item::Enum(declared) = item { + for variant in &declared.variants { + read_fields(path, &variant.fields, reading); + } + } else if let syn::Item::Fn(declared) = item { + read_signature(path, &declared.sig, reading); + } else if let syn::Item::Type(declared) = item { + read_seat(path, &declared.ty, reading); + } else if let syn::Item::Const(declared) = item { + read_seat(path, &declared.ty, reading); + } else if let syn::Item::Static(declared) = item { + read_seat(path, &declared.ty, reading); + } else if let syn::Item::Trait(declared) = item { + for member in &declared.items { + read_trait_member(path, member, reading); + } + } else if let syn::Item::Mod(module) = item + && let Some((_, inner)) = &module.content + { + read_items(&format!("{path}::{}", module.ident), inner, reading); + } + } +} + +/// Reads one implementation: which ladder it declares for which family, and the +/// seats its own members declare. +fn read_implementation(path: &str, declared: &syn::ItemImpl, reading: &mut Reading) { + if let Some((contract, _)) = &declared.trait_ + && let Some(contract) = last_segment(contract) + && let Some(family) = head_of(&declared.self_ty) + { + record_ladder(path, &contract, &family, reading); + } + for member in &declared.items { + if let syn::ImplItem::Fn(road) = member { + read_signature(path, &road.sig, reading); + } else if let syn::ImplItem::Const(held) = member { + read_seat(path, &held.ty, reading); + } else if let syn::ImplItem::Type(named) = member { + read_seat(path, &named.ty, reading); + } + } +} + +/// Records what one trait implementation says about one family. +/// +/// A contract this law has no question about — and the machine declares many — +/// leaves the reading untouched, which is why the family map holds limit +/// families rather than every type that implements anything. +fn record_ladder(path: &str, contract: &str, family: &str, reading: &mut Reading) { + if contract != LIMIT_CONTRACT && contract != DECLARED_LADDER && contract != RUNTIME_LADDER { + return; + } + let facts = reading.families.entry(family.to_owned()).or_default(); + if contract == LIMIT_CONTRACT { + facts.declared_at.insert(path.to_owned()); + } + facts.ladders.insert(contract.to_owned()); +} + +/// Reads one trait's members for the seats their declarations carry. +fn read_trait_member(path: &str, member: &syn::TraitItem, reading: &mut Reading) { + if let syn::TraitItem::Fn(road) = member { + read_signature(path, &road.sig, reading); + } else if let syn::TraitItem::Const(held) = member { + read_seat(path, &held.ty, reading); + } else if let syn::TraitItem::Type(named) = member + && let Some((_, stated)) = &named.default + { + read_seat(path, stated, reading); + } +} + +/// Reads the seats one record's fields declare, named or positional alike. +fn read_fields(path: &str, fields: &syn::Fields, reading: &mut Reading) { + for field in fields { + read_seat(path, &field.ty, reading); + } +} + +/// Reads the seats one signature declares: what the road takes and what it hands +/// back. The body is not read — see the module documentation. +fn read_signature(path: &str, signature: &syn::Signature, reading: &mut Reading) { + for taken in &signature.inputs { + if let syn::FnArg::Typed(named) = taken { + read_seat(path, &named.ty, reading); + } + } + if let syn::ReturnType::Type(_, handed) = &signature.output { + read_seat(path, handed, reading); + } +} + +/// Records every family one declared type seats, at any depth inside it. +/// +/// A seat behind a reference, inside a wrapper, or in a tuple is the same seat: +/// what decides membership is that an inhabitant-promising type names the family +/// as its bound, not where in the type that promise sits. Written as an `if let` +/// chain because `syn::Type` is `non_exhaustive`; the kinds a declared seat in +/// this repository is written in are named, and a seat written behind +/// `impl Trait`, a trait object, or a macro is outside this reading and is not +/// claimed to be inside it. +fn read_seat(path: &str, declared: &syn::Type, reading: &mut Reading) { + if let syn::Type::Path(typed) = declared { + if let Some(qualified) = &typed.qself { + read_seat(path, &qualified.ty, reading); + } + for segment in &typed.path.segments { + read_segment(path, segment, reading); + } + } else if let syn::Type::Reference(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Ptr(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Paren(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Group(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Slice(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Array(held) = declared { + read_seat(path, &held.elem, reading); + } else if let syn::Type::Tuple(held) = declared { + for inner in &held.elems { + read_seat(path, inner, reading); + } + } else if let syn::Type::FnPtr(road) = declared { + for taken in &road.inputs { + read_seat(path, &taken.ty, reading); + } + if let syn::ReturnType::Type(_, handed) = &road.output { + read_seat(path, handed, reading); + } + } +} + +/// Reads one path segment: whether it IS an inhabitant-promising seat, and +/// whatever its own arguments seat one level down. +/// +/// The bound is the LAST type argument, because that is where both promising +/// types carry their family — `NonEmptyBounded` and +/// `AdmittedPrefix`. Every type argument is then descended into, so a +/// promise wrapped in anything is still read. +fn read_segment(path: &str, segment: &syn::PathSegment, reading: &mut Reading) { + let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return; + }; + let bounds: Vec<&syn::Type> = arguments.args.iter().filter_map(type_argument).collect(); + if INHABITANT_PROMISING_SEATS.contains(&segment.ident.to_string().as_str()) + && let Some(family) = bounds.last().and_then(|last| head_of(last)) + { + reading + .seated + .entry(family) + .or_insert_with(|| path.to_owned()); + } + for inner in bounds { + read_seat(path, inner, reading); + } +} + +/// The type one generic argument states, where it states a type at all: a +/// lifetime and a constant are arguments too, and neither is a seat. +fn type_argument(argument: &syn::GenericArgument) -> Option<&syn::Type> { + if let syn::GenericArgument::Type(stated) = argument { + Some(stated) + } else { + None + } +} + +/// The last path segment of one type, or `None` where the type is not a plain +/// path. +/// +/// Generic arguments are not part of the head, so `Refusal` and `Refusal` +/// name one type here. +fn head_of(declared: &syn::Type) -> Option { + if let syn::Type::Path(typed) = declared { + last_segment(&typed.path) + } else { + None + } +} + +/// The last segment of one path, by name. +fn last_segment(path: &syn::Path) -> Option { + path.segments.last().map(|last| last.ident.to_string()) +} + +/// Every parsed source the population is derived from: the machine's own +/// sources, minus the proof surface. +/// +/// Taken from the one reading. A source the snapshot could not parse refuses +/// the whole law rather than leaving the population one file short — a +/// denominator that shrank in silence is the single failure a derived +/// denominator exists to prevent. +fn positivity_sources( + snapshot: &RepositorySnapshot, +) -> Result, String> { + Ok(snapshot + .rust() + .parsed_under(&[MACHINE_DIRECTORY])? + .into_iter() + .filter(|(path, _)| path.as_str() != PROOF_SURFACE) + .collect()) +} + +/// The families one reading found seated, by name, for the reversals that +/// assert which seats a declaration is read through. +#[cfg(test)] +fn seated_families(sources: &[(&CanonicalPath, &syn::File)]) -> Vec { + read_sources(sources).seated.into_keys().collect() +} + +/// Planted reversals for the positivity leg, and the real tree judged by it. +/// +/// Every leg is pure over `(path, text)` pairs, so a reversal is a fixture held +/// in memory: the law that guards the ladder is never proven by taking a family +/// off it. The test that reads the real tree is named `the_real_…` and states +/// what it found rather than what it hoped for. +#[cfg(test)] +mod tests { + use super::{ + PositivityVerdict, positivity_sources, positivity_verdict as verdict_of_trees, + seated_families as seated_of_trees, + }; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; + + /// One synthetic source file. + fn source(text: &str) -> Vec<(String, String)> { + vec![(String::from("src/00_home/types.rs"), text.to_string())] + } + + /// The parsed form of a fixture set, or the offences of the ones that did + /// not parse. + fn parse(sources: &[(String, String)]) -> (Vec<(CanonicalPath, syn::File)>, Vec) { + let mut parsed = Vec::new(); + let mut unparsable = Vec::new(); + for (path, text) in sources { + match syn::parse_file(text) { + Ok(file) => parsed.push((CanonicalPath::spelled(path), file)), + Err(error) => unparsable.push(format!( + "{path}: this file is not parseable Rust, so the population derived from it \ + is unknown rather than empty: {error}" + )), + } + } + (parsed, unparsable) + } + + /// The verdict over fixture source TEXT. + /// + /// The law itself is handed trees the snapshot already parsed, so a source + /// it could not read never reaches it. A fixture is text, so this adapter + /// parses one and reports a fixture that does not parse exactly as the + /// reading reports a source it could not read. + fn positivity_verdict(sources: &[(String, String)]) -> PositivityVerdict { + let (parsed, unparsable) = parse(sources); + let trees: Vec<(&CanonicalPath, &syn::File)> = + parsed.iter().map(|(path, file)| (path, file)).collect(); + let mut verdict = verdict_of_trees(&trees); + verdict.offenders.splice(0..0, unparsable); + verdict + } + + /// The families one fixture set seats, by name. + fn seated_families(sources: &[(String, String)]) -> Vec { + let (parsed, _) = parse(sources); + let trees: Vec<(&CanonicalPath, &syn::File)> = + parsed.iter().map(|(path, file)| (path, file)).collect(); + seated_of_trees(&trees) + } + + /// A family on the runtime ladder, seated in a record that promises an + /// inhabitant. + const WITNESSED: &str = "\ + pub struct DemoIssueLimit;\n\ + impl Limit for DemoIssueLimit {\n\ + \x20 type Authority = EvidenceSelectedMagnitude;\n\ + }\n\ + impl crate::types::EvidenceSelectedLimit for DemoIssueLimit {}\n\ + pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + }\n"; + + /// The same family with the runtime ladder taken off it: the seat still + /// promises an inhabitant and nothing can establish the promise. + const OFF_THE_LADDER: &str = "\ + pub struct DemoIssueLimit;\n\ + impl Limit for DemoIssueLimit {\n\ + \x20 type Authority = UnstatedMagnitude;\n\ + }\n\ + pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + }\n"; + + /// The positive control: a seated family with no compile-time magnitude, + /// declaring the runtime ladder, is lawful and IS counted. A law that + /// flagged everything would satisfy every reversal below and be worthless. + #[test] + fn a_seated_family_on_the_runtime_ladder_is_lawful() { + let verdict = positivity_verdict(&source(WITNESSED)); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.witnessed, 1); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// Planted reversal, and the first of the two directions this leg must see: + /// a seat that promises an inhabitant while its family stays off the ladder. + #[test] + fn a_seat_promising_an_inhabitant_off_the_ladder_is_a_violation() { + let verdict = positivity_verdict(&source(OFF_THE_LADDER)); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.witnessed, 0); + assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); + assert!( + verdict + .offenders + .first() + .is_some_and(|offence| offence.contains("puts it on neither ladder")), + "{:?}", + verdict.offenders + ); + } + + /// The second direction: a family that GAINS a witness moves the numerator. + /// + /// One body, read twice, differing by the one line that puts the family on + /// the ladder. This is what makes the printed numerator a derivation rather + /// than a constant: it moved because the tree moved, and a reader that + /// returned a fixed number would fail here rather than in production. + #[test] + fn a_family_that_gains_a_witness_moves_the_numerator() { + let before = positivity_verdict(&source(OFF_THE_LADDER)); + let after = positivity_verdict(&source(WITNESSED)); + assert_eq!(before.declared, after.declared, "the denominator moved"); + assert_eq!(before.witnessed, 0); + assert_eq!(after.witnessed, 1); + assert_eq!(before.offenders.len(), 1, "{:?}", before.offenders); + assert!(after.offenders.is_empty(), "{:?}", after.offenders); + } + + /// A family with a compile-time magnitude is outside this population + /// entirely: the declared road is open to it, `PositiveLimit` proves its + /// positivity before the program runs, and the runtime ladder is not the + /// road it takes. + #[test] + fn a_family_with_a_declared_magnitude_is_not_this_laws_subject() { + let verdict = positivity_verdict(&source( + "pub struct DemoIssueLimit;\n\ + impl Limit for DemoIssueLimit {\n\ + \x20 type Authority = DeclaredMagnitude;\n\ + }\n\ + impl ConstLimit for DemoIssueLimit {\n\ + \x20 const MAX: usize = 32;\n\ + }\n\ + pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + }\n", + )); + assert_eq!(verdict.declared, 0); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// A family that bounds no inhabitant-promising seat is outside it too. A + /// `Bounded` seat admits an empty collection on purpose, so a magnitude of + /// zero is an honest selection for it and no positivity is promised. + #[test] + fn a_family_bounding_no_inhabitant_promising_seat_is_not_in_the_population() { + let verdict = positivity_verdict(&source( + "pub struct DemoTextLimit;\n\ + impl Limit for DemoTextLimit {}\n\ + pub struct DemoText {\n\ + \x20 letters: Bounded,\n\ + }\n", + )); + assert_eq!(verdict.declared, 0); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// Both inhabitant-promising seats are read, and read through whatever the + /// declaration wraps them in: a reference handed back by an accessor, and a + /// wrapper around the package. + #[test] + fn a_seat_is_read_through_the_declaration_that_wraps_it() { + let seated = seated_families(&source( + "impl DemoRefusal {\n\ + \x20 pub const fn issues(&self) -> &NonEmptyBounded {\n\ + \x20 self.body.carried()\n\ + \x20 }\n\ + }\n\ + pub struct Held {\n\ + \x20 held: Option>,\n\ + \x20 listed: [NonEmptyBounded; 2],\n\ + }\n", + )); + assert_eq!( + seated, + vec!["ArrayedLimit", "ReturnedLimit", "WrappedLimit"] + ); + } + + /// A generic parameter is not a family. A signature bounded on `L: Limit` + /// seats whatever its caller supplies, and reading `L` as a family would put + /// a name no owner declared into the population — where, declaring neither + /// ladder, it would refuse forever. + #[test] + fn a_generic_parameter_is_not_a_family() { + let verdict = positivity_verdict(&source( + "pub struct AdmittedPrefix {\n\ + \x20 carried: NonEmptyBounded,\n\ + }\n\ + impl AdmittedPrefix {\n\ + \x20 pub const fn carried(&self) -> &NonEmptyBounded {\n\ + \x20 &self.carried\n\ + \x20 }\n\ + }\n", + )); + assert_eq!(verdict.declared, 0); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// A `let` binding is not a seat. Its type came from a seat somewhere else, + /// and counting it would derive a population out of function bodies that no + /// owner declared. + #[test] + fn a_binding_inside_a_body_is_not_a_seat() { + let seated = seated_families(&source( + "pub struct BoundLimit;\n\ + impl Limit for BoundLimit {}\n\ + fn demonstrate() {\n\ + \x20 let held: NonEmptyBounded = NonEmptyBounded::singleton(5);\n\ + \x20 drop(held);\n\ + }\n", + )); + assert!(seated.is_empty(), "{seated:?}"); + } + + /// A family and the seat that bounds it are read across the files of one + /// crate: the machine declares its families in `types.rs` and seats several + /// of them in the accessors beside them, and a reader scoped to one file + /// would drop half of every pair. + #[test] + fn a_family_and_its_seat_are_read_across_files() { + let verdict = positivity_verdict(&[ + ( + String::from("src/13_home/types.rs"), + String::from( + "pub struct SplitLimit;\nimpl crate::types::Limit for SplitLimit {}\n", + ), + ), + ( + String::from("src/13_home/type_guard.rs"), + String::from( + "impl SplitRefusal {\n pub const fn issues(&self) -> &NonEmptyBounded {\n self.body.carried()\n }\n}\n", + ), + ), + ]); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.witnessed, 0); + assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); + } + + /// Planted reversal: two homes declaring one terminal name. The seat side + /// names its bound by that name alone, so a reading that merged them would + /// fold one home's ladder onto the other home's seat — and would do it in + /// the direction that makes the offence vanish. + /// + /// The fixture is built exactly that way: the home WITHOUT the runtime + /// ladder carries the inhabitant-promising seat, and the home WITH it + /// carries none. Merged, the record would read as declared-and-witnessed and + /// this leg would pass while guarding nothing. Refused, the collision is + /// what the run reports. + #[test] + fn one_terminal_name_declared_by_two_homes_is_refused() { + let verdict = positivity_verdict(&[ + ( + String::from("src/08_home/types.rs"), + String::from( + "pub struct IssueLimit;\n\ + impl Limit for IssueLimit {\n type Authority = UnstatedMagnitude;\n}\n\ + pub struct EightRefusal {\n body: AdmittedPrefix,\n}\n", + ), + ), + ( + String::from("src/13_home/types.rs"), + String::from( + "pub struct IssueLimit;\n\ + impl Limit for IssueLimit {\n type Authority = EvidenceSelectedMagnitude;\n}\n\ + impl crate::types::EvidenceSelectedLimit for IssueLimit {}\n", + ), + ), + ]); + assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); + assert!( + verdict + .offenders + .first() + .is_some_and(|offence| offence.contains("src/08_home/types.rs") + && offence.contains("src/13_home/types.rs") + && offence.contains("takes a name of its own")), + "{:?}", + verdict.offenders + ); + assert_eq!( + verdict.declared, 0, + "a refused name must leave the population rather than be counted under one of its \ + two declarations" + ); + assert_eq!(verdict.witnessed, 0); + } + + /// The same refusal inside ONE file. A site is the file plus the inline + /// module chain, so two `mod` blocks spelling one family alike collide + /// exactly as two files do — and a reader keyed on the file alone would + /// have merged this pair without a word. + #[test] + fn one_terminal_name_in_two_inline_modules_is_refused() { + let verdict = positivity_verdict(&source( + "pub mod first {\n\ + \x20 pub struct IssueLimit;\n\ + \x20 impl Limit for IssueLimit {\n\ + \x20 type Authority = UnstatedMagnitude;\n\ + \x20 }\n\ + }\n\ + pub mod second {\n\ + \x20 pub struct IssueLimit;\n\ + \x20 impl Limit for IssueLimit {\n\ + \x20 type Authority = UnstatedMagnitude;\n\ + \x20 }\n\ + }\n", + )); + assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); + assert!( + verdict + .offenders + .first() + .is_some_and(|offence| offence.contains("types.rs::first") + && offence.contains("types.rs::second")), + "{:?}", + verdict.offenders + ); + } + + /// The positive control for the collision refusal: one family declared once + /// and mentioned many times is not a collision. The ladder impl, the seat, + /// and the record all name it, and a reader that counted MENTIONS rather + /// than declaration SITES would refuse every family in the machine. + #[test] + fn one_family_named_many_times_at_one_site_is_not_a_collision() { + let verdict = positivity_verdict(&source(WITNESSED)); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.witnessed, 1); + } + + /// The real machine, measured: no terminal name is declared twice. Stated as + /// a test rather than as a sentence in a report, because a sentence would + /// have been true on the day it was written and unchecked every day after. + #[test] + fn the_real_machine_declares_no_family_name_twice() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = positivity_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + let collisions: Vec<&String> = verdict + .offenders + .iter() + .filter(|offence| offence.contains("takes a name of its own")) + .collect(); + assert!(collisions.is_empty(), "{collisions:?}"); + Ok(()) + } + + /// A source this reader cannot parse is a hole in the population, and it is + /// reported as one. Silently reading it as "no families here" is the exact + /// failure the derived denominator exists to prevent. + #[test] + fn an_unparsable_source_is_an_offence_rather_than_an_absence() { + let verdict = positivity_verdict(&source("impl Limit for {\n")); + assert_eq!(verdict.declared, 0); + assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); + assert!( + verdict + .offenders + .first() + .is_some_and(|offence| offence.contains("not parseable Rust")), + "{:?}", + verdict.offenders + ); + } + + /// The real machine holds: every family bounding an inhabitant-promising + /// seat without a compile-time magnitude is on the runtime ladder, and the + /// derived population is real rather than empty. + /// + /// The count is asserted as a RELATION and never as a number. A test naming + /// eight would be the hand list this leg exists to replace, moved one file + /// over; the run prints the numbers, and the relation is what has to hold. + #[test] + fn the_real_machine_witnesses_every_inhabitant_promising_limit() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = positivity_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + assert!(verdict.offenders.is_empty(), "{verdict:?}"); + assert!( + verdict.declared > 0, + "no inhabitant-promising limit family found in the real machine: {verdict:?}" + ); + assert_eq!( + verdict.witnessed, verdict.declared, + "the machine seats a family that promises an inhabitant and is on neither ladder" + ); + Ok(()) + } +} diff --git a/xtask/src/checks/scratch.rs b/xtask/src/checks/scratch.rs index a7e6a90..c970e4c 100644 --- a/xtask/src/checks/scratch.rs +++ b/xtask/src/checks/scratch.rs @@ -1,20 +1,26 @@ //! The scratch root the tree-shaped reversals are planted against. //! -//! Several laws read a directory rather than a text, so a fixture string cannot +//! Several laws judge a TREE rather than a text, so a fixture string cannot //! reach them: what they judge is what a tree contains. They are planted against -//! a scratch root under the platform's temp directory instead. Nothing is -//! written inside the repository — the laws that guard the tree are never proven -//! by dirtying the tree — and each root is removed when its fixture drops. +//! a scratch root under the platform's temp directory instead, and read through +//! the same snapshot builder the real run uses — a law proven against a reading +//! built by different machinery would be a law proven against a different model. +//! Nothing is written inside the repository — the laws that guard the tree are +//! never proven by dirtying the tree — and each root is removed when its fixture +//! drops. //! //! This module exists only under `cfg(test)`: it is fixture machinery shared by -//! four law families, and it ships in no binary. +//! several law families, and it ships in no binary. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; +use crate::repository::snapshot::RepositorySnapshot; + /// One scratch root outside the repository, and the files planted in it. pub(crate) struct Scratch { + /// Where the fixture tree stands. root: PathBuf, } @@ -43,9 +49,20 @@ impl Scratch { let _written = fs::write(&path, contents); } - /// The scratch root, as a check reads it. - pub(crate) fn root(&self) -> &Path { - &self.root + /// Removes one planted file, so a reversal can plant an absence. + pub(crate) fn remove(&self, relative: &str) { + let _removed = fs::remove_file(self.root.join(relative)); + } + + /// The reading of the fixture tree, taken by the builder the real run uses. + /// + /// A fixture root is not a workspace and not a checkout, so what cargo + /// resolved and what git says are DECLARED absences here rather than empty + /// values. A law that needs either is refused against a fixture, which is + /// the honest answer and is why the laws that read a fixture are the laws + /// that need neither. + pub(crate) fn read(&self) -> Result { + RepositorySnapshot::read(&self.root) } } diff --git a/xtask/src/checks/seal.rs b/xtask/src/checks/seal.rs deleted file mode 100644 index 00ba47e..0000000 --- a/xtask/src/checks/seal.rs +++ /dev/null @@ -1,1135 +0,0 @@ -//! The seal: a stamped scope guard has one road in and no road out. -//! -//! Band 02's `scope_guard_version!` stamp writes a Class-C guard as a private -//! seat holding one `AuthorityPosition`, one road in — `positioned` — and one -//! comparison that reads the seat from inside. The asymmetry IS the law the -//! stamp exists to carry: a position that can leave a role can be re-entered -//! under a different role, and a role a representation can leave has stopped -//! being a wall. -//! -//! # Why a reversal cannot state this claim, and why this law can -//! -//! `testpak/tests/compile-fail/a-production-scope-guard-cannot-be-laundered.rs` -//! attempts the two roads a laundering caller has today: reading the seat as -//! `version.0`, and re-entering it as `FrameVersion(position)`. Both refuse, and -//! both keep refusing after a public `position()` or `into_position()` is added -//! to the guard — the field stays private and the tuple constructor stays -//! unreachable, so the recorded diagnostic does not move by one byte while the -//! sealed value walks out through a road with a name. A fixture can only attempt -//! roads somebody thought of; the absence of EVERY road out is not a sentence -//! Rust can be asked to refuse. -//! -//! So the absence is established here instead, by reading what the tree -//! declares. The population is DERIVED — every type the stamp is invoked for, -//! read off the sources — and so is the seat: `AuthorityPosition` is not written -//! down in this file, it is read out of the stamp's own transcriber, so a stamp -//! reseated over a different inner type is judged over the type it actually -//! seals. -//! -//! # The two places a road out can be written, and both are read -//! -//! One is the stamp itself. An accessor added to the transcriber arrives on -//! every guard the machine stamps at once, which is the worst version of this -//! defect and the cheapest to write. The other is a hand-written implementation -//! beside a guard, which reaches the private seat because a `macro_rules!` -//! expansion is expanded IN the invoking module and its field is private to that -//! module. Both are read. -//! -//! A transcriber is not Rust until its metavariables have values, so it is given -//! values — `$vis` becomes `pub`, `$crate` becomes `crate`, every other -//! metavariable becomes an ordinary identifier, and a repetition contributes its -//! body once — and the result is parsed as Rust. A transcriber that will not -//! parse under that substitution is an offence rather than a skip: a stamp this -//! law cannot read is a stamp this law is not guarding. -//! -//! # What a road out is -//! -//! A public associated function or method of a guarded type whose RETURN -//! mentions every sealed seat. Named and positional seats are read alike, a -//! reference is a road out exactly as an owned value is — `AuthorityPosition` -//! is `Clone`, so a borrow of it is one clone away from a re-wrappable value — -//! and an associated TYPE is read the same way, because `Deref` hands the seat -//! out through `Target` and never through a signature that names it. Every -//! member of a trait implementation is public: a trait's own visibility decides -//! that, and no `pub` appears on the member. -//! -//! The road out under another name is read too: an implementation that takes a -//! guarded type in its trait arguments and stands FOR the sealed seat — -//! `impl From for AuthorityPosition<…>` — hands the seat back out -//! without declaring a single member on the guard. -//! -//! # What this reader does not resolve -//! -//! It compiles nothing. A path is read by its LAST SEGMENT, so -//! `AuthorityPosition` and `identity::AuthorityPosition` are one seat here -//! and a type alias to either is neither. It does not evaluate `cfg`: a member -//! written under one is read as declared. It reads roads whose OWNER is the -//! guarded type, so a road declared on some third type that takes a guard by -//! reference and returns something read off it is outside this law — that shape -//! is a reification with its own claim to make, not a representation walking -//! out. And a stamped guard reached only through a macro that composes the -//! stamp's name from fragments is outside it as well. - -use std::fs; -use std::path::Path; - -use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream, TokenTree}; - -use crate::repository::walk::{TOOLING_DIRECTORY, relative_slash_path, visit_files}; - -/// The proof surfaces, excluded from the population by name. -/// -/// Both crates' `laws.rs` stamp demonstration guards so the guard law has -/// something to prove itself against. A fixture in a denominator about the -/// machine would inflate the count with roles nothing ships. -const PROOF_SURFACES: [&str; 2] = ["src/laws.rs", "macros/macroc/src/laws.rs"]; - -/// The stamp whose invocation declares a sealed representation. -const SEAL_STAMP: &str = "scope_guard_version"; - -/// The type-level marker seat, which seals no value. -/// -/// A `PhantomData` field carries nothing a caller could take away, so a road -/// returning one hands out nothing. Counting it as a sealed seat would make -/// every guard whose only private field is a marker read as sealed by this law -/// while the law had said nothing at all. -const MARKER_SEAT: &str = "PhantomData"; - -/// Every stamped scope guard in the machine seals its position: no public road -/// of a guard hands the position back out, and neither does the stamp that -/// writes them. -/// -/// # Errors -/// -/// Returns the offences one line at a time, and returns a read failure as -/// itself: a gate that cannot read its subject says so rather than reporting an -/// empty population. -pub(crate) fn check_stamped_guards_seal_their_position(root: &Path) -> Result<(), String> { - let sources = seal_sources(root)?; - let verdict = seal_verdict(&sources); - - // The denominator is DERIVED and printed on every run, because a population - // that quietly shrank would otherwise keep this check passing while it - // guarded less. - println!( - "stamped scope guards: {} sealed / {} stamped", - verdict.sealed, verdict.stamped - ); - if verdict.stamped == 0 { - return Err(String::from( - "no stamped scope guard was found: this denominator cannot be empty while the guards \ - exist, so the reader is looking at the wrong tree", - )); - } - if verdict.offenders.is_empty() { - Ok(()) - } else { - Err(verdict.offenders.join("; ")) - } -} - -/// What the seal leg counted, and what it refuses. -#[derive(Debug)] -struct SealVerdict { - /// Types the stamp is invoked for. - stamped: usize, - /// Those of them no road hands a position out of. - sealed: usize, - /// Every offence, one line each. - offenders: Vec, -} - -/// One type the stamp is invoked for. -struct StampedGuard { - /// The repository-relative path that stamps it. - path: String, - /// The type the invocation names. - name: String, -} - -/// One implementation, as this law reads one. -struct Implementation { - /// The repository-relative path that declares it. - path: String, - /// The head of the type it is written for. - owner: Option, - /// Every path head the implemented-for type mentions. - stands_for: Vec, - /// Every path head the implemented trait's own arguments mention, which is - /// where a conversion names the type it converts FROM. - takes: Vec, - /// Every member it declares. - members: Vec, -} - -/// One member of an implementation, and what it hands its caller. -struct Member { - /// Its declared name. - name: String, - /// How far a caller can reach it from. - reach: Reach, - /// Every path head the member's return type — or, for an associated type, - /// its value — mentions. - hands_out: Vec, -} - -/// How far one member of an implementation reaches. -/// -/// Every member of a trait implementation is `Outside`: the trait's own -/// visibility decides that, and no `pub` is written on the member. -#[derive(PartialEq, Eq)] -enum Reach { - /// Reachable from outside the crate that declares it. - Outside, - /// Reachable only from inside. - Inside, -} - -/// Everything one pass over the sources read. -struct Reading { - /// Every struct declared, by name, with the seats it seals. - seats: Vec<(String, Vec)>, - /// Every implementation, in every module the pass entered. - implementations: Vec, - /// Every type the stamp is invoked for. - stamped: Vec, - /// Every declaration of the stamp itself, as the transcriber tokens it - /// carries. - stamps: Vec<(String, TokenStream)>, - /// Sources that are not parseable Rust, one offence each. Never a skip: a - /// file this reader could not read is a hole in the population. - unparsable: Vec, -} - -/// Reads the stamp, its invocations, and every implementation out of source -/// text, and judges each stamped guard. -/// -/// Pure over its inputs — `(repository-relative path, source text)` pairs — so -/// the reversals below are planted in memory and the law that guards the tree is -/// never proven by editing one. -fn seal_verdict(sources: &[(String, String)]) -> SealVerdict { - let reading = read_sources(sources); - let mut verdict = SealVerdict { - stamped: reading.stamped.len(), - sealed: 0, - offenders: reading.unparsable.clone(), - }; - let opened = verdict.offenders.len(); - let seats = match reading.stamps.len() { - 1 => stamp_seats(&reading, &mut verdict.offenders), - 0 => { - verdict.offenders.push(format!( - "no `{SEAL_STAMP}!` declaration was found: the seat this law judges guards against \ - is read off the stamp, so a tree without one is a tree this law is not reading" - )); - Vec::new() - } - _ => { - verdict.offenders.push(format!( - "two `{SEAL_STAMP}!` declarations stand in the tree, so which shape a guard is \ - stamped in is a traversal order rather than a fact" - )); - Vec::new() - } - }; - // A road the stamp itself emits arrives on every guard at once, so no guard - // is sealed while one stands. The offence is reported once, at the stamp, - // and the numerator says what it costs. - let stamp_holds = verdict.offenders.len() == opened; - if seats.is_empty() { - return verdict; - } - for guard in &reading.stamped { - let before = verdict.offenders.len(); - judge( - &reading, - &guard.name, - Some(&guard.path), - &seats, - &mut verdict.offenders, - ); - if stamp_holds && verdict.offenders.len() == before { - verdict.sealed = verdict.sealed.saturating_add(1); - } - } - verdict -} - -/// The seats the stamp seals, read out of the one transcriber it carries — and -/// the stamp's own emitted roads, judged against them here, because a road the -/// stamp writes arrives on every guard at once. -fn stamp_seats(reading: &Reading, offenders: &mut Vec) -> Vec { - let Some((path, tokens)) = reading.stamps.first() else { - return Vec::new(); - }; - // The transcriber's own coordinate, so an offence at the stamp cannot read - // as an offence in the file's ordinary items. - let coordinate = format!("{path} (the `{SEAL_STAMP}!` transcriber)"); - let mut emitted = Vec::new(); - for transcriber in transcribers(tokens.clone()) { - match syn::parse2::(substituted(transcriber)) { - Ok(file) => { - let mut shape = empty_reading(); - read_module(&coordinate, &file.items, &mut shape); - emitted.push(shape); - } - Err(error) => offenders.push(format!( - "{path}: the `{SEAL_STAMP}!` transcriber does not parse as Rust once its \ - metavariables are given values, so what the stamp emits is unknown rather than \ - empty: {error}" - )), - } - } - let mut seats = Vec::new(); - for shape in &emitted { - emitted_seats(shape, &mut seats, offenders); - } - if seats.is_empty() { - offenders.push(format!( - "{path}: the `{SEAL_STAMP}!` transcriber declares no private seat, so there is no \ - sealed position for this law to be about" - )); - } - seats -} - -/// Every seat one emitted shape seals, collected once each, with the shape's -/// own roads judged against the seats they belong to. -fn emitted_seats(shape: &Reading, seats: &mut Vec, offenders: &mut Vec) { - for (name, sealed) in &shape.seats { - if sealed.is_empty() { - continue; - } - judge(shape, name, None, sealed, offenders); - for seat in sealed { - if !seats.contains(seat) { - seats.push(seat.clone()); - } - } - } -} - -/// Judges one guarded name against every implementation one reading holds, -/// pushing one offence per road out. -/// -/// `stamped_at` is where the invocation that declares the guard stands, and it -/// is named in every offence because the road out and the declaration it -/// unseals are routinely in two different files. -fn judge( - reading: &Reading, - guard: &str, - stamped_at: Option<&str>, - seats: &[String], - offenders: &mut Vec, -) { - let declared_at = match stamped_at { - Some(where_stamped) => format!(", on the guard stamped at {where_stamped}"), - None => String::new(), - }; - for declared in &reading.implementations { - judge_one(declared, guard, &declared_at, seats, offenders); - } -} - -/// Judges one implementation against one guarded name. -fn judge_one( - declared: &Implementation, - guard: &str, - declared_at: &str, - seats: &[String], - offenders: &mut Vec, -) { - let path = &declared.path; - if declared.owner.as_deref() == Some(guard) { - let roads = declared - .members - .iter() - .filter(|member| member.reach == Reach::Outside) - .filter(|member| hands_back(&member.hands_out, seats)); - for road in roads { - offenders.push(format!( - "{path}: `{guard}::{}` hands the sealed position back out{declared_at}; the stamp \ - emits one road in and none out, and a position that can leave its role can be \ - re-entered under another one", - road.name - )); - } - } - if declared.takes.iter().any(|taken| taken == guard) && hands_back(&declared.stands_for, seats) - { - offenders.push(format!( - "{path}: an implementation takes `{guard}` and stands for its sealed \ - position{declared_at}, which is the road out written as a conversion" - )); - } -} - -/// Whether one return hands every sealed seat back. -/// -/// An empty seat set never satisfies this: a guard sealing nothing is a guard -/// this law has no claim about, and reading it as satisfied would turn silence -/// into coverage. -fn hands_back(hands_out: &[String], seats: &[String]) -> bool { - !seats.is_empty() && seats.iter().all(|seat| hands_out.contains(seat)) -} - -/// An empty reading, so the pass and the transcriber's own shape are built the -/// same way. -fn empty_reading() -> Reading { - Reading { - seats: Vec::new(), - implementations: Vec::new(), - stamped: Vec::new(), - stamps: Vec::new(), - unparsable: Vec::new(), - } -} - -/// Parses every source and reads the stamp, its invocations, and the -/// implementations out of the trees. -fn read_sources(sources: &[(String, String)]) -> Reading { - let mut reading = empty_reading(); - for (path, text) in sources { - match syn::parse_file(text) { - Ok(file) => read_module(path, &file.items, &mut reading), - Err(error) => reading.unparsable.push(format!( - "{path}: this file is not parseable Rust, so the population derived from it is \ - unknown rather than empty: {error}" - )), - } - } - reading -} - -/// Reads one module's items, then every inline module inside it. -fn read_module(path: &str, items: &[syn::Item], reading: &mut Reading) { - for item in items { - if let syn::Item::Struct(declared) = item { - reading - .seats - .push((declared.ident.to_string(), sealed_seats(&declared.fields))); - } else if let syn::Item::Impl(declared) = item { - reading.implementations.push(implementation(path, declared)); - } else if let syn::Item::Macro(declared) = item { - read_macro(path, declared, reading); - } else if let syn::Item::Mod(module) = item - && let Some((_, inner)) = &module.content - { - read_module(path, inner, reading); - } - } -} - -/// Reads one macro item: the stamp's own declaration, or one invocation of it. -fn read_macro(path: &str, declared: &syn::ItemMacro, reading: &mut Reading) { - if let Some(name) = &declared.ident { - if name == SEAL_STAMP { - reading - .stamps - .push((path.to_string(), declared.mac.tokens.clone())); - } - return; - } - if last_segment(&declared.mac.path).is_none_or(|last| last != SEAL_STAMP) { - return; - } - match stamped_name(declared.mac.tokens.clone()) { - Some(name) => reading.stamped.push(StampedGuard { - path: path.to_string(), - name, - }), - None => reading.unparsable.push(format!( - "{path}: a `{SEAL_STAMP}!` invocation names no stamped struct, so the guard it declares \ - cannot be judged" - )), - } -} - -/// The seats one body seals: the declared type heads of its PRIVATE fields, -/// each once, without the marker seat. -/// -/// A public field seals nothing — it is already the caller's — so it is not a -/// seat this law is about. -fn sealed_seats(fields: &syn::Fields) -> Vec { - let declared: Vec<&syn::Field> = match *fields { - syn::Fields::Named(ref named) => named.named.iter().collect(), - syn::Fields::Unnamed(ref unnamed) => unnamed.unnamed.iter().collect(), - syn::Fields::Unit => Vec::new(), - }; - let mut seats = Vec::new(); - for field in declared { - if matches!(field.vis, syn::Visibility::Public(_)) { - continue; - } - let Some(head) = head_of(&field.ty) else { - continue; - }; - if head == MARKER_SEAT || seats.contains(&head) { - continue; - } - seats.push(head); - } - seats -} - -/// One implementation, read as this law reads one. -fn implementation(path: &str, declared: &syn::ItemImpl) -> Implementation { - let mut stands_for = Vec::new(); - mentions(&declared.self_ty, &mut stands_for); - let mut takes = Vec::new(); - let contract = declared.trait_.as_ref().map(|(named, _)| named); - if let Some(named) = contract { - mentions_arguments(named, &mut takes); - } - let mut members = Vec::new(); - for member in &declared.items { - if let syn::ImplItem::Fn(function) = member { - let mut hands_out = Vec::new(); - if let syn::ReturnType::Type(_, output) = &function.sig.output { - mentions(output, &mut hands_out); - } - members.push(Member { - name: function.sig.ident.to_string(), - reach: reach_of(contract, &function.vis), - hands_out, - }); - } else if let syn::ImplItem::Type(associated) = member { - let mut hands_out = Vec::new(); - mentions(&associated.ty, &mut hands_out); - members.push(Member { - name: associated.ident.to_string(), - reach: reach_of(contract, &associated.vis), - hands_out, - }); - } - } - Implementation { - path: path.to_string(), - owner: head_of(&declared.self_ty), - stands_for, - takes, - members, - } -} - -/// How far one member reaches: as far as its own word says inside an inherent -/// implementation, and always outside inside a trait implementation, where the -/// trait's own visibility decides and no word is written on the member. -/// -/// The contract arrives as the path it is rather than as a flag, because a flag -/// beside the path it was computed from is a second thing to keep true. -fn reach_of(contract: Option<&syn::Path>, declared: &syn::Visibility) -> Reach { - if contract.is_some() || matches!(*declared, syn::Visibility::Public(_)) { - Reach::Outside - } else { - Reach::Inside - } -} - -/// Every path head one declared type mentions, at any depth. -/// -/// A type this reader does not recognize contributes nothing, which is the -/// conservative direction: it can leave a road unjudged, and it can never -/// invent one. -fn mentions(declared: &syn::Type, into: &mut Vec) { - if let syn::Type::Path(typed) = declared { - if let Some(qualified) = &typed.qself { - mentions(&qualified.ty, into); - } - mentions_path(&typed.path, into); - } else if let syn::Type::Reference(borrowed) = declared { - mentions(&borrowed.elem, into); - } else if let syn::Type::Ptr(pointer) = declared { - mentions(&pointer.elem, into); - } else if let syn::Type::Tuple(tuple) = declared { - for element in &tuple.elems { - mentions(element, into); - } - } else if let syn::Type::Slice(sliced) = declared { - mentions(&sliced.elem, into); - } else if let syn::Type::Array(array) = declared { - mentions(&array.elem, into); - } else if let syn::Type::Paren(parenthesized) = declared { - mentions(&parenthesized.elem, into); - } else if let syn::Type::Group(grouped) = declared { - mentions(&grouped.elem, into); - } else if let syn::Type::ImplTrait(opaque) = declared { - mentions_bounds(&opaque.bounds, into); - } else if let syn::Type::TraitObject(object) = declared { - mentions_bounds(&object.bounds, into); - } -} - -/// Every path head one path mentions: each segment, and everything inside its -/// arguments. -fn mentions_path(path: &syn::Path, into: &mut Vec) { - for segment in &path.segments { - into.push(segment.ident.to_string()); - mentions_segment_arguments(&segment.arguments, into); - } -} - -/// Every path head one path's ARGUMENTS mention, without the path's own -/// segments — which is where a conversion names the type it converts from. -fn mentions_arguments(path: &syn::Path, into: &mut Vec) { - for segment in &path.segments { - mentions_segment_arguments(&segment.arguments, into); - } -} - -/// Every path head one segment's arguments mention. -fn mentions_segment_arguments(arguments: &syn::PathArguments, into: &mut Vec) { - if let syn::PathArguments::AngleBracketed(angled) = arguments { - for argument in &angled.args { - if let syn::GenericArgument::Type(inner) = argument { - mentions(inner, into); - } else if let syn::GenericArgument::AssocType(associated) = argument { - mentions(&associated.ty, into); - } - } - } else if let syn::PathArguments::Parenthesized(parenthesized) = arguments { - for input in &parenthesized.inputs { - mentions(&input.ty, into); - } - if let syn::ReturnType::Type(_, output) = &parenthesized.output { - mentions(output, into); - } - } -} - -/// Every path head one bound list mentions. -fn mentions_bounds( - bounds: &syn::punctuated::Punctuated, - into: &mut Vec, -) { - for bound in bounds { - if let syn::TypeParamBound::Trait(contract) = bound { - mentions_path(&contract.path, into); - } - } -} - -/// The last path segment of one type, or `None` where the type is not a plain -/// path. -fn head_of(declared: &syn::Type) -> Option { - if let syn::Type::Path(typed) = declared { - last_segment(&typed.path) - } else { - None - } -} - -/// The last segment of one path, by name. -fn last_segment(path: &syn::Path) -> Option { - path.segments.last().map(|last| last.ident.to_string()) -} - -/// The type one stamp invocation names, read off its tokens: the identifier -/// that follows the `struct` word. -fn stamped_name(tokens: TokenStream) -> Option { - let mut trees = tokens.into_iter(); - while let Some(tree) = trees.next() { - if let TokenTree::Ident(word) = tree - && word == "struct" - { - if let Some(TokenTree::Ident(name)) = trees.next() { - return Some(name.to_string()); - } - return None; - } - } - None -} - -/// Every transcriber one `macro_rules!` body carries: the braced body that -/// follows each `=>`. -fn transcribers(tokens: TokenStream) -> Vec { - let mut found = Vec::new(); - let mut arrow = 0_u8; - for tree in tokens { - if let TokenTree::Punct(punct) = &tree { - let character = punct.as_char(); - if character == '=' { - arrow = 1; - continue; - } - if character == '>' && arrow == 1 { - arrow = 2; - continue; - } - arrow = 0; - continue; - } - if let TokenTree::Group(group) = &tree - && arrow == 2 - && group.delimiter() == Delimiter::Brace - { - found.push(group.stream()); - } - arrow = 0; - } - found -} - -/// One transcriber with its metavariables given values, so `syn` can read it as -/// the Rust it becomes. -/// -/// A repetition contributes its body once, which is exactly what an expansion -/// of one invocation does with a single-element repetition and is enough for -/// every question this law asks: what a repeated doc comment repeats is not a -/// road. -fn substituted(tokens: TokenStream) -> TokenStream { - let mut out: Vec = Vec::new(); - let mut trees = tokens.into_iter(); - while let Some(tree) = trees.next() { - if !is_dollar(&tree) { - if let TokenTree::Group(group) = &tree { - out.push(TokenTree::Group(Group::new( - group.delimiter(), - substituted(group.stream()), - ))); - } else { - out.push(tree); - } - continue; - } - let Some(next) = trees.next() else { - break; - }; - if let TokenTree::Ident(name) = &next { - out.push(TokenTree::Ident(Ident::new( - &stand_in(&name.to_string()), - Span::call_site(), - ))); - continue; - } - if let TokenTree::Group(group) = &next - && group.delimiter() == Delimiter::Parenthesis - { - out.extend(substituted(group.stream())); - consume_repetition(&mut trees); - continue; - } - out.push(next); - } - out.into_iter().collect() -} - -/// Whether one token is the metavariable sigil. -fn is_dollar(tree: &TokenTree) -> bool { - if let TokenTree::Punct(punct) = tree { - punct.as_char() == '$' - } else { - false - } -} - -/// Consumes a repetition's optional separator and its operator, so neither -/// lands in the substituted text. -fn consume_repetition(trees: &mut impl Iterator) { - for tree in trees { - if let TokenTree::Punct(punct) = &tree { - let character = punct.as_char(); - if character == '*' || character == '+' || character == '?' { - return; - } - } - } -} - -/// The value one metavariable is given. -/// -/// A visibility metavariable becomes `pub`, because the widest reach a caller -/// can ask the stamp for is the reach this law must judge. `$crate` becomes -/// `crate`, which is what it resolves to inside the crate that declares the -/// stamp. Everything else becomes an ordinary identifier, which is a lawful -/// type name, a lawful attribute path, and a lawful item name at once. -fn stand_in(name: &str) -> String { - if name == "vis" { - return String::from("pub"); - } - if name == "crate" { - return String::from("crate"); - } - let mut characters = name.chars(); - match characters.next() { - Some(first) => first.to_uppercase().chain(characters).collect(), - None => String::from("Metavariable"), - } -} - -/// Every source file the population is derived from: the machine's own sources -/// and the services', minus the two proof surfaces. -fn seal_sources(root: &Path) -> Result, String> { - let mut sources = Vec::new(); - for directory in ["src", TOOLING_DIRECTORY] { - let base = root.join(directory); - if !base.is_dir() { - continue; - } - visit_files(&base, &mut |path| { - if path.extension().is_none_or(|extension| extension != "rs") { - return Ok(()); - } - let relative = relative_slash_path(root, path); - if PROOF_SURFACES.contains(&relative.as_str()) { - return Ok(()); - } - let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - sources.push((relative, text)); - Ok(()) - })?; - } - Ok(sources) -} - -/// Planted reversals for the seal, and the real repository judged by it. -/// -/// Every leg is pure over `(path, text)` pairs, so a reversal is a fixture held -/// in memory: the law that guards the machine's guards is never proven by -/// unsealing one. The test that reads the real tree is named `the_real_…` and -/// states what it found rather than what it hoped for. -#[cfg(test)] -mod tests { - use super::{SealVerdict, seal_sources, seal_verdict}; - use crate::repository::walk::repo_root; - - /// The stamp as the machine writes it: one road in, one comparison that - /// reads the seat from inside, and no road out. - const LAWFUL_STAMP: &str = "\ -macro_rules! scope_guard_version { - ( - $(#[$note:meta])* - $vis:vis struct $name:ident over $scope:ty; - ) => { - $(#[$note])* - #[derive(Debug, Clone, PartialEq, Eq, Hash)] - $vis struct $name($crate::identity::AuthorityPosition<$scope>); - - impl $name { - /// The one road in. - #[must_use] - $vis fn positioned( - position: $crate::identity::AuthorityPosition<$scope>, - ) -> Self { - Self(position) - } - - /// The one lawful comparison. - $vis fn try_cmp_same_scope( - &self, - other: &Self, - ) -> ::core::result::Result< - ::core::cmp::Ordering, - $crate::identity::OrderComparison, - > { - self.0.try_cmp_same_scope(&other.0) - } - } - }; -} -"; - - /// One invocation of the stamp. - const ONE_INVOCATION: &str = "\ -crate::scope_guard_version! { - /// One version of a reference frame. - pub struct FrameVersion over ReferenceFrameId; -} -"; - - /// The stamp, one invocation, and whatever else a reversal adds. - fn tree(extra: &str) -> Vec<(String, String)> { - vec![ - ( - String::from("src/02_identity/mod.rs"), - String::from(LAWFUL_STAMP), - ), - ( - String::from("src/11_navigation/types.rs"), - format!("{ONE_INVOCATION}{extra}"), - ), - ] - } - - /// Whether some offence says the named thing. - fn says(verdict: &SealVerdict, fragment: &str) -> bool { - verdict - .offenders - .iter() - .any(|offence| offence.contains(fragment)) - } - - /// The positive control: the stamp as written, one guard, no road out. A - /// check that flagged everything would satisfy every reversal below and be - /// worthless. - #[test] - fn a_stamped_guard_with_no_road_out_is_lawful() { - let verdict = seal_verdict(&tree("")); - assert_eq!(verdict.stamped, 1); - assert_eq!(verdict.sealed, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// Planted reversal: the accessor the stamp's own contract forbids, added - /// to the transcriber. It arrives on every guard the machine stamps at - /// once, and the compile-refusal fixture beside it goes on refusing for its - /// original reason. - #[test] - fn an_accessor_in_the_stamp_is_a_violation() { - let unsealed = LAWFUL_STAMP.replace( - " /// The one lawful comparison.", - " /// The road out.\n\ - \x20 #[must_use]\n\ - \x20 $vis fn position(&self) -> $crate::identity::AuthorityPosition<$scope> {\n\ - \x20 self.0.clone()\n\ - \x20 }\n\ - \n\ - \x20 /// The one lawful comparison.", - ); - let verdict = seal_verdict(&[ - (String::from("src/02_identity/mod.rs"), unsealed), - ( - String::from("src/11_navigation/types.rs"), - String::from(ONE_INVOCATION), - ), - ]); - // One offence, at the stamp, and no guard counted sealed: the accessor - // is on all of them, and reporting it twelve times would bury it. - assert_eq!(verdict.stamped, 1); - assert_eq!(verdict.sealed, 0, "{:?}", verdict.offenders); - assert_eq!(verdict.offenders.len(), 1, "{:?}", verdict.offenders); - assert!(says(&verdict, "position"), "{:?}", verdict.offenders); - assert!(says(&verdict, "transcriber"), "{:?}", verdict.offenders); - assert!( - says(&verdict, "hands the sealed position back out"), - "{:?}", - verdict.offenders - ); - } - - /// Planted reversal: a hand-written accessor beside one guard. The - /// expansion happened in this module, so its private seat is reachable from - /// exactly here. - #[test] - fn a_hand_written_accessor_is_a_violation() { - let verdict = seal_verdict(&tree( - "impl FrameVersion {\n\ - \x20 #[must_use]\n\ - \x20 pub fn into_position(self) -> AuthorityPosition {\n\ - \x20 self.0\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.stamped, 1); - assert_eq!(verdict.sealed, 0); - assert!(says(&verdict, "into_position"), "{:?}", verdict.offenders); - } - - /// Planted reversal: the seat handed out through an associated TYPE. A - /// reader that only looked at return signatures would report this tree - /// sealed while `*version` yielded the position. - #[test] - fn a_deref_target_is_a_violation() { - let verdict = seal_verdict(&tree( - "impl core::ops::Deref for FrameVersion {\n\ - \x20 type Target = AuthorityPosition;\n\ - \x20 fn deref(&self) -> &Self::Target {\n\ - \x20 &self.0\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.sealed, 0); - assert!(says(&verdict, "Target"), "{:?}", verdict.offenders); - } - - /// Planted reversal: a trait implementation's member carries no `pub`, and - /// is public anyway. A reader that asked for the keyword would let every - /// conversion trait through. - #[test] - fn a_trait_member_is_public_without_the_word() { - let verdict = seal_verdict(&tree( - "impl FrameVersion {\n\ - \x20 fn hidden(self) -> AuthorityPosition {\n\ - \x20 self.0\n\ - \x20 }\n\ - }\n\ - \n\ - impl AsRef> for FrameVersion {\n\ - \x20 fn as_ref(&self) -> &AuthorityPosition {\n\ - \x20 &self.0\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.sealed, 0); - assert!(says(&verdict, "as_ref"), "{:?}", verdict.offenders); - assert!(!says(&verdict, "hidden"), "{:?}", verdict.offenders); - } - - /// Planted reversal: the road out written as a conversion, declaring no - /// member on the guard at all. - #[test] - fn a_conversion_into_the_seat_is_a_violation() { - let verdict = seal_verdict(&tree( - "impl From for AuthorityPosition {\n\ - \x20 fn from(version: FrameVersion) -> Self {\n\ - \x20 version.0\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.sealed, 0); - assert!( - says(&verdict, "written as a conversion"), - "{:?}", - verdict.offenders - ); - } - - /// The reader's narrowness, stated as a test: a road returning something - /// else is not a road out, a private road is not a public one, and a road - /// on a type nobody stamped is nobody's laundering. - #[test] - fn the_reader_counts_roads_out_and_nothing_else() { - let verdict = seal_verdict(&tree( - "impl FrameVersion {\n\ - \x20 pub fn scope(&self) -> ReferenceFrameId {\n\ - \x20 self.0.scope()\n\ - \x20 }\n\ - \x20 fn seat(&self) -> &AuthorityPosition {\n\ - \x20 &self.0\n\ - \x20 }\n\ - }\n\ - \n\ - impl SomethingElse {\n\ - \x20 pub fn position(&self) -> AuthorityPosition {\n\ - \x20 self.0.clone()\n\ - \x20 }\n\ - }\n", - )); - assert_eq!(verdict.stamped, 1); - assert_eq!(verdict.sealed, 1); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - } - - /// A guard stamped privately gets private operations, and a private road - /// out is still a road out inside its own module. The stamp carries the - /// caller's own visibility, so this law reads the widest reach the stamp - /// can be asked for rather than the narrowest. - #[test] - fn the_transcriber_is_read_at_the_widest_visibility_it_can_carry() { - let verdict = seal_verdict(&tree("")); - assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); - assert_eq!(verdict.sealed, verdict.stamped); - } - - /// A source this reader cannot parse is a hole in the population, and it is - /// reported as one. - #[test] - fn an_unparsable_source_is_an_offence_rather_than_an_absence() { - let verdict = seal_verdict(&[( - String::from("src/11_navigation/types.rs"), - String::from("impl FrameVersion for {\n"), - )]); - assert!( - says(&verdict, "not parseable Rust"), - "{:?}", - verdict.offenders - ); - } - - /// A transcriber this reader cannot parse is the same hole one level in, - /// and it is reported rather than skipped: a stamp nobody could read is a - /// stamp nobody is guarding. - #[test] - fn an_unreadable_transcriber_is_an_offence_rather_than_a_skip() { - let verdict = seal_verdict(&[ - ( - String::from("src/02_identity/mod.rs"), - String::from("macro_rules! scope_guard_version { () => { struct } ; }"), - ), - ( - String::from("src/11_navigation/types.rs"), - String::from(ONE_INVOCATION), - ), - ]); - assert_eq!(verdict.sealed, 0); - assert!( - says(&verdict, "does not parse as Rust"), - "{:?}", - verdict.offenders - ); - } - - /// A tree with invocations and no stamp is a tree this law is not reading, - /// and it says so rather than reporting every guard sealed. - #[test] - fn a_missing_stamp_is_an_offence_rather_than_a_pass() { - let verdict = seal_verdict(&[( - String::from("src/11_navigation/types.rs"), - String::from(ONE_INVOCATION), - )]); - assert_eq!(verdict.stamped, 1); - assert_eq!(verdict.sealed, 0); - assert!( - says(&verdict, "declaration was found"), - "{:?}", - verdict.offenders - ); - } - - /// The seat is READ off the transcriber rather than written down here: a - /// stamp reseated over a different inner type is judged over the type it - /// actually seals. - #[test] - fn the_seat_is_read_off_the_stamp_rather_than_named_here() { - let reseated = LAWFUL_STAMP.replace("AuthorityPosition", "SomeOtherSeat"); - let verdict = seal_verdict(&[ - (String::from("src/02_identity/mod.rs"), reseated), - ( - String::from("src/11_navigation/types.rs"), - format!( - "{ONE_INVOCATION}impl FrameVersion {{\n\ - \x20 pub fn out(self) -> SomeOtherSeat {{\n\ - \x20 self.0\n\ - \x20 }}\n\ - }}\n" - ), - ), - ]); - assert_eq!(verdict.sealed, 0); - assert!( - says(&verdict, "`FrameVersion::out`"), - "{:?}", - verdict.offenders - ); - } - - /// The real repository holds: every guard the stamp writes seals its - /// position, and the derived population is real rather than empty. - /// - /// A gate that cannot READ its subject says it could not read its subject. - #[test] - fn the_real_tree_seals_every_stamped_guard() { - let read = repo_root() - .map_err(|error| format!("the repository root could not be found: {error}")) - .and_then(|root| seal_sources(&root)) - .map(|sources| seal_verdict(&sources)); - assert!( - read.is_ok(), - "the seal gate could not read its subject: {read:?}" - ); - assert!( - read.as_ref() - .is_ok_and(|verdict| verdict.offenders.is_empty()), - "{read:?}" - ); - assert!( - read.as_ref().is_ok_and(|verdict| verdict.stamped > 0), - "no stamped scope guard found in the real tree: {read:?}" - ); - assert!( - read.is_ok_and(|verdict| verdict.sealed == verdict.stamped), - "the real tree stamps a guard whose position has a road out" - ); - } -} diff --git a/xtask/src/checks/seat.rs b/xtask/src/checks/seat.rs new file mode 100644 index 0000000..e465e41 --- /dev/null +++ b/xtask/src/checks/seat.rs @@ -0,0 +1,828 @@ +//! The seat law: a module named `seat` carries one record and nothing else. +//! +//! # The defect this replaces, and why it could not be checked +//! +//! Rust's privacy is MODULE-scoped. A private field is private to the module +//! its declaration landed in, and to that module's descendants — so a record +//! declared in a home's `types.rs` puts every other item in that file inside its +//! wall. Two repository laws used to stand on the other side of that fact and +//! ask, of a whole file at a time, *did anybody write a road that hands the +//! sealed value out?* Answering it means resolving types, following aliases, +//! deciding what a receiver stands for, and inferring reachability from +//! visibility and module chains. Both laws tried; between them they were wrong +//! twelve times, in twelve different Rust shapes, and every repair taught the +//! reader one more shape while leaving the thirteenth open. +//! +//! The defect is not in the readers. It is that the question is unanswerable +//! without being a compiler, and it was only ever asked because the wall was +//! drawn around a file full of unrelated code. +//! +//! So the wall moved. Each sealed record now sits in a module of its own — +//! named `seat`, for the private field it exists to hold — whose entire content +//! is that record and inherent implementations of it. What can reach the seat is +//! now a module a person reads in one screen rather than a file with dozens of +//! types in it, and `rustc` is what refuses everything outside it: `E0616` on +//! the field, `E0451` on the literal, `E0603` on a tuple constructor. Those are +//! compiler refusals, not findings. +//! +//! The stamped scope guards took the same move one step further: their module is +//! written BY `scope_guard_version!`, so no hand-written item can be inside the +//! wall at all and no law is left with anything to say about them. That is the +//! drain running downward — the compiler took the claim, and the law that +//! asserted it went. +//! +//! # What is left for a law, and why this one cannot be wrong +//! +//! One thing: the `seat` module has to STAY a module with nothing else in it. A +//! helper function added beside the record, a trait implementation, a nested +//! module, a macro invocation whose expansion nobody here can see — each one +//! quietly widens the set of code inside the wall, and the widening is invisible +//! at the declaration. +//! +//! That is a question about ITEM KINDS and IDENTIFIERS, and this reader asks +//! nothing else. It resolves no type, follows no alias, expands no macro, and +//! reads no visibility. +//! +//! `impl Foo` belongs to a `seat` module declaring `Foo` because the two +//! identifiers are spelled the same, and because Rust will not let them mean two +//! different things: a module that declares `struct Foo` cannot also import +//! another `Foo` into its type namespace — `rustc` refuses that pair outright — +//! so an UNQUALIFIED, single-segment `Foo` written inside such a module names +//! that module's own record and can name nothing else. Nothing is resolved to +//! read that; it follows from what the module declares. +//! +//! A QUALIFIED subject breaks the argument at its root, and is refused for that +//! reason rather than read. Rust admits an inherent implementation in a module +//! other than the one its type was declared in, as long as both sit in one +//! crate, so `impl crate::other::Foo` written here puts a block for somebody +//! else's record INSIDE this seat's privacy wall — free to read the local +//! record's private field, write its literal, and hand the record back — while +//! spelling a name this module never declared. The admitted spelling is +//! therefore the one whose meaning is settled by the declaration beside it: no +//! `qself`, no leading `::`, exactly one segment, that segment the record's own +//! identifier, with generic arguments permitted on it. Everything else refuses, +//! and none of it is resolved. This is a NARROWING of what the reader admits, not +//! a resurrection of the deleted readers that tried to follow a path to its +//! owner. +//! +//! # Its stated ceiling, said out loud +//! +//! **It does not decide which records must be seated.** A closed record declared +//! somewhere else, in a module named anything else, is outside this law's +//! population entirely. For the scope guards that gap is closed by the compiler +//! — a guard exists only where the stamp wrote it, and the stamp always seats it +//! — and for the services' refusal bodies it is closed by the compile-fail +//! fixtures that name each seat from outside the crate. +//! +//! **It does not read what a road hands back.** An inherent road written INSIDE +//! a seat module can still return the seat, and this law will not say so. What +//! it buys is that the set of roads to a seat is a module rather than a file: it +//! is readable, it is small, and it cannot grow sideways without this law +//! refusing. +//! +//! **A `seat` module declared as a FILE is refused rather than read.** This +//! reader judges the module where it is written; a `mod seat;` whose body lives +//! in another file would have its contents judged nowhere at all, and unknown +//! must not read as nothing to say. +//! +//! **Where a `seat` module may be WRITTEN is not restricted, so every place one +//! can be written is entered.** A module at a file's own level, one nested +//! inside another module, and one declared inside a body — a free road's, an +//! implementation's method, a trait's default — are the same wall around the +//! same record, so the walk enters all of them. What it does not enter is an +//! item written inside an EXPRESSION: a block used as a value, a closure body, a +//! match arm. `syn::Expr` is `#[non_exhaustive]`, so a reader that completed +//! itself by listing that enum's variants would be complete only until the +//! decoder gained one more — the set with no last member this repository has +//! already deleted readers for. The gap is named here rather than papered over, +//! and it closes when the one reading gains a visitor owning the whole grammar, +//! not when this law is taught one more shape. +//! +//! **A `seat` module a macro would write is outside the population.** This +//! reader expands nothing, so a module produced by an expansion is not a module +//! it can see. Inside a seat module that fails closed — a macro invocation there +//! is refused by kind — and outside one it is the stated gap above. + +use crate::repository::snapshot::{MACHINE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY}; +use crate::repository::types::CanonicalPath; + +/// The module name a sealed record is declared in. +const SEAT_MODULE: &str = "seat"; + +/// Every module named `seat` in the machine and the services carries exactly one +/// record declaration, and beyond that only inherent implementations of that +/// record and the imports they name. +/// +/// # Errors +/// +/// Returns the offences one line at a time, and returns a read failure as +/// itself: a gate that cannot read its subject says so rather than reporting an +/// empty population. +pub(crate) fn check_seat_modules_carry_nothing_else( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { + let sources = seat_sources(snapshot)?; + let verdict = seat_verdict(&sources); + + // The denominator is DERIVED and printed on every run, because a population + // that quietly shrank would otherwise keep this check passing while it + // guarded less. + println!( + "seat modules: {} carrying one record alone / {} declared", + verdict.closed, verdict.declared + ); + if verdict.declared == 0 { + return Err(String::from( + "no `seat` module was found: this denominator cannot be empty while the sealed records \ + exist, so the reader is looking at the wrong tree", + )); + } + if verdict.offenders.is_empty() { + Ok(()) + } else { + Err(verdict.offenders.join("; ")) + } +} + +/// What the seat leg counted, and what it refuses. +#[derive(Debug)] +struct SeatVerdict { + /// Modules named `seat` the pass entered. + declared: usize, + /// Those of them carrying one record and nothing but implementations of it. + closed: usize, + /// Every offence, one line each. + offenders: Vec, +} + +/// Reads every `seat` module out of parsed trees and judges each one. +/// +/// Pure over its inputs — `(canonical path, parsed tree)` pairs handed over by +/// the snapshot — so the reversals below are planted in memory and the law that +/// guards the seats is never proven by opening one. A source that did not parse +/// never reaches here: the snapshot carries it as unread, and the caller refuses +/// the whole reading rather than deriving a population one file short. +fn seat_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> SeatVerdict { + let mut verdict = SeatVerdict { + declared: 0, + closed: 0, + offenders: Vec::new(), + }; + for (path, file) in sources { + walk(path.as_str(), &file.items, &mut verdict); + } + verdict +} + +/// Walks one item list, judging every `seat` module it declares and descending +/// into every other item that carries one — a module's body, and the bodies a +/// road, a method, or a trait default carries. +/// +/// No item kind reaches a silent `continue`: an item either declares a module +/// this law judges or is entered for the items IT carries, and an item carrying +/// none contributes an empty list rather than an exemption. +fn walk<'items>( + path: &str, + items: impl IntoIterator, + verdict: &mut SeatVerdict, +) { + for item in items { + let syn::Item::Mod(module) = item else { + walk(path, beneath(item), verdict); + continue; + }; + let named_seat = module.ident == SEAT_MODULE; + let Some((_, inner)) = &module.content else { + if named_seat { + verdict.declared = verdict.declared.saturating_add(1); + verdict.offenders.push(format!( + "{path}: `mod {SEAT_MODULE};` carries its body in another file, so what the \ + seat module holds is judged nowhere; a seat module is written where it is \ + declared" + )); + } + continue; + }; + if named_seat { + verdict.declared = verdict.declared.saturating_add(1); + judge(path, inner, verdict); + } + walk(path, inner, verdict); + } +} + +/// The items one item carries in a body of its own. +/// +/// A module can be declared inside a body, and a body is one of exactly three +/// things: a free road's block, an implementation method's block, or a trait +/// method's default block. Each is entered, because a `seat` module written in +/// one is the same wall around the same record as one written at a file's own +/// level. An item carrying no body carries no items, which is a fact rather than +/// a skip. +fn beneath(item: &syn::Item) -> Vec<&syn::Item> { + if let syn::Item::Fn(declared) = item { + return declared_items(&declared.block.stmts); + } + if let syn::Item::Impl(declared) = item { + let mut found = Vec::new(); + for member in &declared.items { + if let syn::ImplItem::Fn(road) = member { + found.extend(declared_items(&road.block.stmts)); + } + } + return found; + } + if let syn::Item::Trait(declared) = item { + let mut found = Vec::new(); + for member in &declared.items { + if let syn::TraitItem::Fn(road) = member + && let Some(body) = road.default.as_ref() + { + found.extend(declared_items(&body.stmts)); + } + } + return found; + } + Vec::new() +} + +/// The items one block's statements declare. +fn declared_items(statements: &[syn::Stmt]) -> Vec<&syn::Item> { + statements + .iter() + .filter_map(|statement| { + if let syn::Stmt::Item(declared) = statement { + Some(declared) + } else { + None + } + }) + .collect() +} + +/// Judges the items of one `seat` module. +fn judge(path: &str, items: &[syn::Item], verdict: &mut SeatVerdict) { + let opened = verdict.offenders.len(); + let mut records: Vec = Vec::new(); + for item in items { + if let syn::Item::Struct(declared) = item { + records.push(declared.ident.to_string()); + } + } + match records.len() { + 1 => {} + 0 => verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module declares no record at all, so it is a wall drawn \ + around nothing" + )), + several => verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module declares {several} records, so each of them is \ + inside the other's wall and neither seat is a module a reader can read alone" + )), + } + for item in items { + judge_item(path, item, &records, verdict); + } + if verdict.offenders.len() == opened { + verdict.closed = verdict.closed.saturating_add(1); + } +} + +/// Judges one item of a `seat` module against the records it declares. +/// +/// Three kinds stand: the record declarations themselves, the imports they name, +/// and inherent implementations whose subject is spelled like one of the +/// records. Everything else is refused BY KIND, which is why this reader has +/// nothing to resolve. +fn judge_item(path: &str, item: &syn::Item, records: &[String], verdict: &mut SeatVerdict) { + if matches!(*item, syn::Item::Struct(_) | syn::Item::Use(_)) { + return; + } + if let syn::Item::Impl(declared) = item { + judge_implementation(path, declared, records, verdict); + return; + } + verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module carries {}, and a seat module carries its one record, the \ + imports that record names, and inherent implementations of it — nothing else, because \ + everything written inside the module is inside the seat's wall", + described(item) + )); +} + +/// Judges one implementation written inside a `seat` module. +fn judge_implementation( + path: &str, + declared: &syn::ItemImpl, + records: &[String], + verdict: &mut SeatVerdict, +) { + if declared.trait_.is_some() { + verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module carries a trait implementation, which hands the seat \ + out through a contract's own members rather than through a road with a name; a trait \ + implementation that does not need the seat belongs outside the module, and one that \ + does is the road this wall exists to make readable" + )); + return; + } + match subject_of(&declared.self_ty) { + ImplementationSubject::Declared(subject) => { + if !records.contains(&subject) { + verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module carries an implementation of `{subject}`, \ + which is not the record it declares; a seat module is the wall around ONE \ + record and an implementation of anything else is other code standing inside it" + )); + } + } + ImplementationSubject::Qualified(spelled) => verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module carries an implementation of `{spelled}`, a \ + QUALIFIED path. Rust admits an inherent implementation in a module other than the one \ + its type was declared in, so this block sits inside the seat's privacy wall — able to \ + read the record's private field, write its literal, and hand the record back — while \ + naming a type declared somewhere else; a seat module's implementations name its one \ + record unqualified and in one segment, because that is the only spelling settled by \ + the declaration beside it" + )), + ImplementationSubject::Unnamed => verdict.offenders.push(format!( + "{path}: a `{SEAT_MODULE}` module carries an implementation whose subject is not a \ + plain name, so whether it is an implementation of this module's record is unknown \ + rather than yes" + )), + } +} + +/// What one implementation inside a `seat` module names as its subject. +/// +/// Three states, and only the first can be this module's record. The second is +/// the one this reader used to collapse into the first by keeping a path's last +/// segment: `impl crate::other::Foo` inside a module declaring `Foo` reduced to +/// `Foo` and was admitted as a road to the local record, while it was a road to +/// somebody else's record standing inside the local record's wall. +#[derive(Debug)] +enum ImplementationSubject { + /// An unqualified, single-segment path. A module declaring `struct Foo` + /// cannot import another `Foo` into its type namespace, so inside such a + /// module this spelling names that module's own declaration. + Declared(String), + /// A path reaching out of the module — a qualified self type, a leading + /// `::`, or more than one segment — carried as spelled, for the refusal. + Qualified(String), + /// Not a path at all. + Unnamed, +} + +/// What one implementation's self type names, classified. +fn subject_of(declared: &syn::Type) -> ImplementationSubject { + let syn::Type::Path(typed) = declared else { + return ImplementationSubject::Unnamed; + }; + if typed.qself.is_some() || typed.path.leading_colon.is_some() { + return ImplementationSubject::Qualified(spelling_of(&typed.path)); + } + let mut segments = typed.path.segments.iter(); + match (segments.next(), segments.next()) { + (Some(only), None) => ImplementationSubject::Declared(only.ident.to_string()), + (Some(_) | None, _) => ImplementationSubject::Qualified(spelling_of(&typed.path)), + } +} + +/// How one path is spelled, for a refusal that has to name what it read. +/// +/// Segment identifiers joined by `::`, with the leading `::` kept where the path +/// carries one. Generic arguments are left out: what the refusal is about is the +/// route, and a route is its segments. +fn spelling_of(path: &syn::Path) -> String { + let mut spelled = String::new(); + if path.leading_colon.is_some() { + spelled.push_str("::"); + } + for (position, segment) in path.segments.iter().enumerate() { + if position > 0 { + spelled.push_str("::"); + } + spelled.push_str(&segment.ident.to_string()); + } + spelled +} + +/// What one refused item is, in the words its own declaration uses. +/// +/// Written as an `if let` chain rather than a match because `syn::Item` is +/// `non_exhaustive`: the kinds this reader has a word for are named, and every +/// other kind falls through to the unrecognized description — which still +/// refuses, because the verdict was already decided by the caller and this +/// function only says what the item is. +fn described(item: &syn::Item) -> &'static str { + if matches!(*item, syn::Item::Fn(_)) { + return "a free function"; + } + if matches!(*item, syn::Item::Mod(_)) { + return "a nested module"; + } + if matches!(*item, syn::Item::Enum(_)) { + return "a second record, spelled as an enum"; + } + if matches!(*item, syn::Item::Trait(_) | syn::Item::TraitAlias(_)) { + return "a trait declaration"; + } + if matches!(*item, syn::Item::Type(_)) { + return "a type alias"; + } + if matches!(*item, syn::Item::Const(_)) { + return "a constant"; + } + if matches!(*item, syn::Item::Static(_)) { + return "a static"; + } + if matches!(*item, syn::Item::Union(_)) { + return "a union"; + } + if let syn::Item::Macro(declared) = item { + return if declared.ident.is_some() { + "a macro definition" + } else { + "a macro invocation, whose expansion this reader cannot see" + }; + } + if matches!(*item, syn::Item::ExternCrate(_)) { + return "an extern-crate declaration"; + } + if matches!(*item, syn::Item::ForeignMod(_)) { + return "a foreign block"; + } + "an item this reader has no name for" +} + +/// Every parsed source the population is derived from: the machine's own +/// sources and the services'. +/// +/// Taken from the one reading. A source the snapshot could not parse refuses +/// the whole law rather than leaving the population one file short — a +/// denominator that shrank in silence is the single failure a derived +/// denominator exists to prevent. +/// +/// No proof surface is excluded. A `seat` module on a proof surface is a seat +/// module, and it is judged: this law reads item kinds rather than semantic +/// roles, so a demonstration seat costs the denominator one honest entry rather +/// than inflating it with a role nothing ships. +fn seat_sources( + snapshot: &RepositorySnapshot, +) -> Result, String> { + snapshot + .rust() + .parsed_under(&[MACHINE_DIRECTORY, TOOLING_DIRECTORY]) +} + +/// Planted reversals for the seat law, and the real tree judged by it. +/// +/// Every leg is pure over `(path, text)` pairs, so a reversal is a fixture held +/// in memory: the law that guards the seats is never proven by opening one. The +/// test that reads the real tree is named `the_real_…` and states what it found +/// rather than what it hoped for. +#[cfg(test)] +mod tests { + use super::{SeatVerdict, seat_sources, seat_verdict as verdict_of_trees}; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; + + /// One synthetic source carrying one `seat` module with the given body. + fn seat(body: &str) -> Vec<(String, String)> { + vec![( + String::from("macros/macroc/src/home/type_guard.rs"), + format!("pub use seat::DemoRefusal;\n\nmod seat {{\n{body}}}\n"), + )] + } + + /// The verdict over fixture source TEXT. + /// + /// The law itself is handed trees the snapshot already parsed, so a source + /// it could not read never reaches it. A fixture is text, so this adapter + /// parses one and reports a fixture that does not parse exactly as the + /// reading reports a source it could not read — which keeps the reversal + /// below about a hole in the population rather than about who parses. + fn seat_verdict(sources: &[(String, String)]) -> SeatVerdict { + let mut parsed = Vec::new(); + let mut unparsable = Vec::new(); + for (path, text) in sources { + match syn::parse_file(text) { + Ok(file) => parsed.push((CanonicalPath::spelled(path), file)), + Err(error) => unparsable.push(format!( + "{path}: this file is not parseable Rust, so the population derived from it \ + is unknown rather than empty: {error}" + )), + } + } + let trees: Vec<(&CanonicalPath, &syn::File)> = + parsed.iter().map(|(path, file)| (path, file)).collect(); + let mut verdict = verdict_of_trees(&trees); + verdict.offenders.splice(0..0, unparsable); + verdict + } + + /// The record and its one crate-internal mint: the shape every seat module + /// in the tree is written in. + const LAWFUL_BODY: &str = "\ + use super::super::DemoIssue;\n\ + use threadpak::refusal::AdmittedPrefix;\n\ +\n\ + /// The demo refusal family body.\n\ + pub struct DemoRefusal {\n\ + body: AdmittedPrefix,\n\ + }\n\ +\n\ + impl DemoRefusal {\n\ + pub(super) fn established(issue: DemoIssue) -> Self {\n\ + Self { body: AdmittedPrefix::carrying_one(issue) }\n\ + }\n\ + }\n"; + + /// Whether some offence says the named thing. + fn says(verdict: &SeatVerdict, fragment: &str) -> bool { + verdict + .offenders + .iter() + .any(|offence| offence.contains(fragment)) + } + + /// The positive control: one record, its imports, and one inherent + /// implementation of it. A check that flagged everything would satisfy every + /// reversal below and be worthless. + #[test] + fn a_seat_module_carrying_one_record_and_its_roads_is_lawful() { + let verdict = seat_verdict(&seat(LAWFUL_BODY)); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.closed, 1); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// Planted reversal: a hand-written free function beside the record. It + /// reaches the seat exactly as the record's own roads do, and it is other + /// code standing inside the wall. + #[test] + fn a_free_function_in_a_seat_module_is_a_violation() { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\ + \x20 fn laundered(held: &DemoRefusal) -> AdmittedPrefix {{\n\ + \x20 held.body.clone()\n\ + \x20 }}\n" + ))); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.closed, 0); + assert!(says(&verdict, "a free function"), "{:?}", verdict.offenders); + } + + /// Planted reversal: a nested module. The seat's privacy does not exclude + /// descendants, so a module written inside the wall constructs the record as + /// freely as the roads beside it do. + #[test] + fn a_nested_module_in_a_seat_module_is_a_violation() { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\ + \x20 mod inner {{\n\ + \x20 pub fn reach() {{}}\n\ + \x20 }}\n" + ))); + assert_eq!(verdict.closed, 0); + assert!(says(&verdict, "a nested module"), "{:?}", verdict.offenders); + } + + /// Planted reversal: a trait implementation, which hands the seat out + /// through a contract's members and never through a road with a name. + #[test] + fn a_trait_implementation_in_a_seat_module_is_a_violation() { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\ + \x20 impl core::ops::Deref for DemoRefusal {{\n\ + \x20 type Target = AdmittedPrefix;\n\ + \x20 fn deref(&self) -> &Self::Target {{ &self.body }}\n\ + \x20 }}\n" + ))); + assert_eq!(verdict.closed, 0); + assert!( + says(&verdict, "a trait implementation"), + "{:?}", + verdict.offenders + ); + } + + /// Planted reversal: a macro invocation. Whether its expansion reaches the + /// seat is unknown, and unknown must not read as no. + #[test] + fn a_macro_invocation_in_a_seat_module_is_a_violation() { + let verdict = seat_verdict(&seat(&format!("{LAWFUL_BODY}\n\x20 launder!();\n"))); + assert_eq!(verdict.closed, 0); + assert!( + says(&verdict, "a macro invocation"), + "{:?}", + verdict.offenders + ); + } + + /// Planted reversal: an implementation of something else. A seat module is + /// the wall around ONE record, and a second subject inside it is a second + /// thing that can reach the seat. + #[test] + fn an_implementation_of_another_subject_is_a_violation() { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\ + \x20 impl DemoIssue {{\n\ + \x20 pub fn into_refusal(self) -> DemoRefusal {{ DemoRefusal::established(self) }}\n\ + \x20 }}\n" + ))); + assert_eq!(verdict.closed, 0); + assert!(says(&verdict, "`DemoIssue`"), "{:?}", verdict.offenders); + } + + /// Planted reversal: the privacy-wall bypass a last-segment reading + /// admitted. + /// + /// Rust permits an inherent implementation in a module other than the one + /// its type was declared in, provided both are in one crate. So each of + /// these blocks really does sit inside the local record's wall — it can read + /// the private field, write the literal, and hand the record out — while its + /// subject was declared somewhere else entirely. A reader keeping the last + /// path segment saw `DemoRefusal` in every one of them and passed. + /// + /// Four spellings, because the qualification hides in four places: a + /// crate-rooted path, a `super`-rooted one, a bare leading `::`, and a + /// qualified self type. None of them is resolved — each is refused for being + /// a spelling whose meaning the declaration beside it does not settle. + #[test] + fn an_implementation_of_a_qualified_subject_is_a_violation() { + for spelling in [ + "crate::other::DemoRefusal", + "super::super::other::DemoRefusal", + "::demo::DemoRefusal", + "::DemoRefusal", + ] { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\ + \x20 impl {spelling} {{\n\ + \x20 pub fn road(&self, local: &DemoRefusal) -> DemoRefusal {{\n\ + \x20 DemoRefusal {{ body: local.body.clone() }}\n\ + \x20 }}\n\ + \x20 }}\n" + ))); + assert_eq!(verdict.declared, 1, "{spelling}"); + assert_eq!(verdict.closed, 0, "{spelling}: {:?}", verdict.offenders); + assert!( + says(&verdict, "a QUALIFIED path"), + "{spelling}: {:?}", + verdict.offenders + ); + } + } + + /// The narrowing refuses the qualification and nothing else: the record's + /// own generic arguments stay lawful on the one segment that carries them. + #[test] + fn generic_arguments_on_the_records_own_name_stay_lawful() { + let verdict = seat_verdict(&seat( + " use threadpak::refusal::AdmittedPrefix;\n\ + \x20 pub struct DemoRefusal {\n\ + \x20 body: AdmittedPrefix,\n\ + \x20 }\n\ + \n\ + \x20 impl DemoRefusal {\n\ + \x20 pub(super) fn established(body: AdmittedPrefix) -> Self {\n\ + \x20 Self { body }\n\ + \x20 }\n\ + \x20 }\n", + )); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.closed, 1, "{:?}", verdict.offenders); + } + + /// A seat module written inside a BODY is still in the population. + /// + /// Planted reversal in the three places a body can be: a free road, an + /// implementation's method, and a trait's default. The walk used to enter + /// item-level modules only, so a wall built inside any of these stood + /// outside the law that guards walls. + #[test] + fn a_seat_module_written_inside_a_body_is_still_in_the_population() { + let offending = "mod seat {\n pub struct Held { body: u64 }\n fn reach() {}\n}\n"; + for surrounding in [ + format!("pub fn road() {{\n{offending}}}\n"), + format!( + "struct Subject;\nimpl Subject {{\n fn road(&self) {{\n{offending} }}\n}}\n" + ), + format!("trait Contract {{\n fn road(&self) {{\n{offending} }}\n}}\n"), + ] { + let verdict = seat_verdict(&[( + String::from("macros/macroc/src/home/type_guard.rs"), + surrounding.clone(), + )]); + assert_eq!(verdict.declared, 1, "{surrounding}"); + assert!( + says(&verdict, "a free function"), + "{surrounding} -> {:?}", + verdict.offenders + ); + } + } + + /// A seat module declaring two records puts each inside the other's wall. + #[test] + fn two_records_in_one_seat_module_is_a_violation() { + let verdict = seat_verdict(&seat(&format!( + "{LAWFUL_BODY}\n\x20 pub struct OtherRefusal {{ body: u8 }}\n" + ))); + assert_eq!(verdict.closed, 0); + assert!(says(&verdict, "2 records"), "{:?}", verdict.offenders); + } + + /// A seat module declaring no record is a wall around nothing. + #[test] + fn a_seat_module_with_no_record_is_a_violation() { + let verdict = seat_verdict(&seat(" use super::super::DemoIssue;\n")); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.closed, 0); + assert!( + says(&verdict, "declares no record at all"), + "{:?}", + verdict.offenders + ); + } + + /// A `seat` module whose body lives in another file is judged nowhere, so it + /// is refused rather than passed over. + #[test] + fn a_seat_module_carried_in_another_file_is_an_offence() { + let verdict = seat_verdict(&[( + String::from("macros/macroc/src/home/type_guard.rs"), + String::from("mod seat;\n"), + )]); + assert_eq!(verdict.declared, 1); + assert!(says(&verdict, "judged nowhere"), "{:?}", verdict.offenders); + } + + /// A seat module nested inside another module is still read: the population + /// is every `seat` module the tree declares, at whatever depth. + #[test] + fn a_seat_module_nested_deeper_is_still_in_the_population() { + let verdict = seat_verdict(&[( + String::from("src/00_home/types.rs"), + format!("mod outer {{\n mod seat {{\n{LAWFUL_BODY} }}\n}}\n"), + )]); + assert_eq!(verdict.declared, 1); + assert_eq!(verdict.closed, 1, "{:?}", verdict.offenders); + } + + /// Every module that is NOT named `seat` is outside this law entirely: it + /// reads a name and nothing else, and a file full of ordinary code is not + /// its subject. + #[test] + fn a_module_that_is_not_a_seat_is_not_this_laws_subject() { + let verdict = seat_verdict(&[( + String::from("src/00_home/types.rs"), + String::from( + "mod guard {\n fn helper() {}\n pub struct One;\n pub struct Two;\n}\n", + ), + )]); + assert_eq!(verdict.declared, 0); + assert!(verdict.offenders.is_empty(), "{:?}", verdict.offenders); + } + + /// A source this reader cannot parse is a hole in the population, and it is + /// reported as one. + #[test] + fn an_unparsable_source_is_an_offence_rather_than_an_absence() { + let verdict = seat_verdict(&[( + String::from("src/00_home/types.rs"), + String::from("mod seat {\n"), + )]); + assert_eq!(verdict.declared, 0); + assert!( + says(&verdict, "not parseable Rust"), + "{:?}", + verdict.offenders + ); + } + + /// The real tree holds: every `seat` module it declares carries one record + /// and nothing else, and the derived population is real rather than empty. + /// + /// The count is asserted as a RELATION and never as a number. A test naming + /// seven would be the hand-maintained inventory this repository bans, moved + /// one file over; the run prints the numbers, and the relation is what has + /// to hold. + #[test] + fn the_real_tree_carries_nothing_else_in_a_seat_module() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = seat_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + assert!(verdict.offenders.is_empty(), "{verdict:?}"); + assert!( + verdict.declared > 0, + "no `seat` module found in the real tree: {verdict:?}" + ); + assert_eq!( + verdict.closed, verdict.declared, + "the real tree carries a `seat` module with something else in it" + ); + Ok(()) + } +} diff --git a/xtask/src/checks/supply_chain.rs b/xtask/src/checks/supply_chain.rs index 57f07d0..29cfea3 100644 --- a/xtask/src/checks/supply_chain.rs +++ b/xtask/src/checks/supply_chain.rs @@ -121,8 +121,8 @@ //! as uncovered. Those stand on a positive invocation alone, the reversal says //! so, and counting the reversal does not discharge them. -use std::fs; -use std::path::{Path, PathBuf}; +use crate::repository::snapshot::RepositorySnapshot; +use crate::repository::types::Read; /// The hosted workflow the dependency gate runs in, in path segments so the /// join spells no separator of its own. @@ -143,38 +143,38 @@ const PLANTED_REVERSAL: &str = "deny-reversal.toml"; /// Every offence is a statement about a committed file, because a committed file /// is the whole of what this reading opens. What a hosted run does with these /// files is named where it is conditional and claimed nowhere. -pub(crate) fn check_dependency_gate_artifacts(root: &Path) -> Result<(), String> { +pub(crate) fn check_dependency_gate_artifacts(snapshot: &RepositorySnapshot) -> Result<(), String> { let mut offences = Vec::new(); - if !gate_workflow(root).is_file() { + let workflow = spelled_workflow(); + if snapshot.files().get(&workflow).is_none() { offences.push(format!( - "the dependency gate's hosted seat is not committed: `{}` is not there, so no file in \ - this repository names a run that would read `{RULE_SET}` or the reversal beside it, \ - and what the resolved graph is allowed to be is settled by nobody", - spelled_workflow() + "the dependency gate's hosted seat is not committed: `{workflow}` is not there, so no \ + file in this repository names a run that would read `{RULE_SET}` or the reversal \ + beside it, and what the resolved graph is allowed to be is settled by nobody" )); } - let rule_set = committed_bytes(root, RULE_SET)?; + let rule_set = committed_bytes(snapshot, RULE_SET)?; if rule_set.is_none() { offences.push(format!( "`{RULE_SET}` is not there: it is the rule set the committed reversal stands against, \ and a reversal standing against a rule set nobody wrote departs from nothing" )); } - match committed_bytes(root, PLANTED_REVERSAL)? { + match committed_bytes(snapshot, PLANTED_REVERSAL)? { None => offences.push(format!( "`{PLANTED_REVERSAL}` is not there: it is this gate's planted reversal, the one \ committed artifact by which the rule's REFUSAL can be watched at all, and nothing in \ the tree now carries a deliberately wrong configuration" )), Some(reversal) => { - if !states_something(&reversal) { + if !states_something(reversal) { offences.push(format!( "`{PLANTED_REVERSAL}` states nothing at all: a configuration carrying no rule \ cannot be wrong, so cargo-deny succeeds against it wherever it is run, and \ the artifact has stopped being a reversal" )); } - if rule_set.as_deref() == Some(reversal.as_slice()) { + if rule_set == Some(reversal) { offences.push(format!( "`{PLANTED_REVERSAL}` is byte-for-byte `{RULE_SET}`: the planted reversal is a \ copy of the rule it stands against, so it is no longer distinct from it and \ @@ -190,15 +190,6 @@ pub(crate) fn check_dependency_gate_artifacts(root: &Path) -> Result<(), String> } } -/// Where the gate's hosted seat sits under one root. -fn gate_workflow(root: &Path) -> PathBuf { - let mut path = root.to_path_buf(); - for segment in GATE_WORKFLOW { - path.push(segment); - } - path -} - /// The workflow as this repository spells paths — relative, forward slashes — /// so a run on any machine names the same file. fn spelled_workflow() -> String { @@ -209,15 +200,18 @@ fn spelled_workflow() -> String { /// /// A part that is ABSENT is an offence this law reports; a part that is present /// and unreadable is a failure of the run itself, because whether it departs -/// from anything is then unknown rather than false. -fn committed_bytes(root: &Path, relative: &str) -> Result>, String> { - let path = root.join(relative); - if !path.is_file() { - return Ok(None); +/// from anything is then unknown rather than false. The three states are the +/// reading's own three states, so neither collapses into the other on its way +/// here. +fn committed_bytes<'snapshot>( + snapshot: &'snapshot RepositorySnapshot, + relative: &str, +) -> Result, String> { + match snapshot.files().bytes(relative) { + Read::Known(bytes) => Ok(Some(bytes)), + Read::DeclaredAbsent(_) => Ok(None), + Read::Unreadable(failure) => Err(format!("{relative} could not be read: {failure}")), } - fs::read(&path) - .map(Some) - .map_err(|e| format!("{}: {e}", path.display())) } /// Whether a committed part states anything at all: one byte that is not @@ -240,9 +234,7 @@ fn states_something(bytes: &[u8]) -> bool { mod tests { use super::{GATE_WORKFLOW, PLANTED_REVERSAL, RULE_SET, check_dependency_gate_artifacts}; use crate::checks::scratch::Scratch; - use crate::repository::walk::repo_root; - use std::fs; - use std::path::PathBuf; + use crate::repository::snapshot::repository_snapshot; /// A fixture rule set, standing for `deny.toml`. const RULE_SET_FIXTURE: &str = "[bans]\nmultiple-versions = \"deny\"\n"; @@ -269,70 +261,71 @@ mod tests { /// against, is lawful. A law that refused everything would satisfy every /// reversal below and be worthless. #[test] - fn present_and_distinct_artifacts_are_lawful() { + fn present_and_distinct_artifacts_are_lawful() -> Result<(), String> { let scratch = planted("gate-whole"); - let found = check_dependency_gate_artifacts(scratch.root()); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!(found.is_ok(), "{found:?}"); + Ok(()) } /// Planted reversal: the reversal artifact deleted. This is the failure the /// law exists for — the one committed thing by which the rule's refusal can /// be watched is gone, and nothing else in the tree notices. #[test] - fn a_deleted_reversal_is_a_violation() { + fn a_deleted_reversal_is_a_violation() -> Result<(), String> { let scratch = planted("gate-reversal-deleted"); - let _removed = fs::remove_file(scratch.root().join(PLANTED_REVERSAL)); - let found = check_dependency_gate_artifacts(scratch.root()); + scratch.remove(PLANTED_REVERSAL); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_err_and( |reason| reason.contains(PLANTED_REVERSAL) && reason.contains("is not there") ), "a deleted reversal passed the law that counts it" ); + Ok(()) } /// Planted reversal: the whole hosted workflow deleted. The gate's two /// configurations survive and no committed file names a run that reads them. #[test] - fn a_deleted_workflow_is_a_violation() { + fn a_deleted_workflow_is_a_violation() -> Result<(), String> { let scratch = planted("gate-workflow-deleted"); - let mut workflow = scratch.root().to_path_buf(); - for segment in GATE_WORKFLOW { - workflow.push(segment); - } - let _removed = fs::remove_file(&workflow); - let found = check_dependency_gate_artifacts(scratch.root()); + scratch.remove(&GATE_WORKFLOW.join("/")); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_err_and(|reason| reason.contains("hosted seat is not committed")), "a deleted workflow passed the law that counts the gate's artifacts" ); + Ok(()) } /// Planted reversal: the rule set deleted, leaving a reversal standing /// against nothing. #[test] - fn a_deleted_rule_set_is_a_violation() { + fn a_deleted_rule_set_is_a_violation() -> Result<(), String> { let scratch = planted("gate-rule-set-deleted"); - let _removed = fs::remove_file(scratch.root().join(RULE_SET)); - let found = check_dependency_gate_artifacts(scratch.root()); + scratch.remove(RULE_SET); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_err_and(|reason| reason.contains(RULE_SET) && reason.contains("is not there")), "a deleted rule set passed" ); + Ok(()) } /// Planted reversal: the reversal emptied rather than deleted — the same /// disappearance written one edit shallower, and the one a file listing /// would report as present. #[test] - fn an_emptied_reversal_is_a_violation() { + fn an_emptied_reversal_is_a_violation() -> Result<(), String> { let scratch = planted("gate-reversal-emptied"); scratch.write(PLANTED_REVERSAL, "\n \n"); - let found = check_dependency_gate_artifacts(scratch.root()); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_err_and(|reason| reason.contains("states nothing at all")), "an emptied reversal passed" ); + Ok(()) } /// Planted reversal: the reversal synchronized with the rule set until it @@ -344,14 +337,15 @@ mod tests { /// one thing the file was committed to be — a configuration that is wrong on /// purpose — has quietly stopped being true of it. #[test] - fn a_reversal_that_stopped_departing_is_a_violation() { + fn a_reversal_that_stopped_departing_is_a_violation() -> Result<(), String> { let scratch = planted("gate-reversal-synchronized"); scratch.write(PLANTED_REVERSAL, RULE_SET_FIXTURE); - let found = check_dependency_gate_artifacts(scratch.root()); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_err_and(|reason| reason.contains("byte-for-byte")), "a reversal that is a copy of its rule set passed" ); + Ok(()) } /// The ceiling, executed rather than only written down: a workflow that @@ -366,26 +360,27 @@ mod tests { /// that deletion is the line of the diff where the claim is allowed to grow /// back. #[test] - fn a_workflow_that_invokes_nothing_still_passes() { + fn a_workflow_that_invokes_nothing_still_passes() -> Result<(), String> { let scratch = planted("gate-workflow-invokes-nothing"); scratch.write( &GATE_WORKFLOW.join("/"), "name: dependencies\non:\n pull_request:\njobs: {}\n", ); - let found = check_dependency_gate_artifacts(scratch.root()); + let found = check_dependency_gate_artifacts(&scratch.read()?); assert!( found.is_ok(), "this law reads files and not steps; a verdict here would mean it had started reading \ steps without its name saying so: {found:?}" ); + Ok(()) } /// The real repository holds: the gate's three artifacts are committed, and /// the reversal states something that is not the rule set. #[test] - fn the_real_gate_artifacts_are_present_and_distinct() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let found = check_dependency_gate_artifacts(&root); + fn the_real_gate_artifacts_are_present_and_distinct() -> Result<(), String> { + let found = check_dependency_gate_artifacts(repository_snapshot()?); assert!(found.is_ok(), "{found:?}"); + Ok(()) } } diff --git a/xtask/src/checks/toolchain.rs b/xtask/src/checks/toolchain.rs index 186774e..c9c6a8f 100644 --- a/xtask/src/checks/toolchain.rs +++ b/xtask/src/checks/toolchain.rs @@ -6,12 +6,33 @@ //! no document, because a reader trusts it — and so does a resolver, and so does //! clippy, which is why the floor is read out of every file that states it //! rather than out of the two that happen to be prose. +//! +//! Every one of those files is read by the decoder that owns its language. The +//! reader this replaced cut a `key = "value"` line at its first `=` and matched +//! `"[lints]\nworkspace = true"` as a substring, which meant a member could +//! carry those exact bytes inside a comment and inherit nothing — a law +//! answering about characters when its subject was a declaration. + +use crate::repository::cargo::{ + Declaration, MANIFEST_FILE, declares_table, declares_yes, string_at, strings_at, +}; +use crate::repository::markdown::phase_declaration; +use crate::repository::snapshot::RepositorySnapshot; +use crate::repository::types::{CanonicalPath, Read}; + +/// The file that pins the channel every build runs under. +const TOOLCHAIN_PIN: &str = "rust-toolchain.toml"; -use std::fs; -use std::path::Path; +/// The file that tells clippy which floor its suggestions must not reach past. +const LINT_CONFIGURATION: &str = "clippy.toml"; -use crate::repository::manifest::{bracket_list, quoted_value}; -use crate::repository::readme::readme_yaml_block; +/// The document a reader is told the floor by. +const ROOT_README: &str = "README.md"; + +/// How the root package's own home is named in a refusal. It is a workspace +/// member Cargo never asks anybody to list, so it has no spelling in the members +/// array and needs one here. +const ROOT_PACKAGE_HOME: &str = "."; /// Every statement of the toolchain floor names the same version. /// @@ -23,94 +44,179 @@ use crate::repository::readme::readme_yaml_block; /// worse than no claim, because each of the three is read by something that /// then behaves differently. The join makes the disagreement unrepresentable /// rather than merely absent. -pub(crate) fn check_toolchain_pin(root: &Path) -> Result<(), String> { - let toolchain_text = fs::read_to_string(root.join("rust-toolchain.toml")) - .map_err(|e| format!("rust-toolchain.toml: {e}"))?; - let pinned = quoted_value(&toolchain_text, "channel")?; - let yaml = readme_yaml_block(root)?; - let declared = yaml - .iter() - .find_map(|line| line.strip_prefix("toolchain: ")) - .map(|value| value.trim_matches('"').to_string()) - .ok_or_else(|| String::from("README yaml block has no toolchain line"))?; - let manifest = - fs::read_to_string(root.join("Cargo.toml")).map_err(|e| format!("Cargo.toml: {e}"))?; - let floor = quoted_value(&manifest, "rust-version")?; - let clippy_text = - fs::read_to_string(root.join("clippy.toml")).map_err(|e| format!("clippy.toml: {e}"))?; - let suggested = quoted_value(&clippy_text, "msrv")?; +pub(crate) fn check_toolchain_pin(snapshot: &RepositorySnapshot) -> Result<(), String> { + let pin = snapshot + .cargo() + .document(TOOLCHAIN_PIN) + .taken(TOOLCHAIN_PIN)?; + let pinned = string_at(pin, &["toolchain", "channel"]).taken("the pinned channel")?; + let readme = snapshot + .markdown() + .document(&CanonicalPath::spelled(ROOT_README)) + .taken(ROOT_README)?; + let phase = phase_declaration(readme).taken("the README phase declaration")?; + let manifest = snapshot + .cargo() + .document(MANIFEST_FILE) + .taken(MANIFEST_FILE)?; + let floor = string_at(manifest, &["workspace", "package", "rust-version"]) + .taken("the workspace rust-version")?; + let configuration = snapshot + .cargo() + .document(LINT_CONFIGURATION) + .taken(LINT_CONFIGURATION)?; + let suggested = string_at(configuration, &["msrv"]).taken("the clippy msrv")?; let mut disagreements = Vec::new(); - if declared != pinned { - disagreements.push(format!("README declares {declared}")); + if phase.toolchain() != pinned { + disagreements.push(format!("README declares {}", phase.toolchain())); } - if floor != pinned { - disagreements.push(format!("Cargo.toml rust-version is {floor}")); + if *floor != *pinned { + disagreements.push(format!("{MANIFEST_FILE} rust-version is {floor}")); } - if suggested != pinned { - disagreements.push(format!("clippy.toml msrv is {suggested}")); + if *suggested != *pinned { + disagreements.push(format!("{LINT_CONFIGURATION} msrv is {suggested}")); } if disagreements.is_empty() { Ok(()) } else { Err(format!( - "rust-toolchain.toml pins {pinned} but {}", + "{TOOLCHAIN_PIN} pins {pinned} but {}", disagreements.join("; ") )) } } -/// The workspace members in `Cargo.toml` match the members the README yaml block -/// declares. -pub(crate) fn check_workspace_members(root: &Path) -> Result<(), String> { - let manifest = - fs::read_to_string(root.join("Cargo.toml")).map_err(|e| format!("Cargo.toml: {e}"))?; - let actual = bracket_list(&manifest, "members")?; - let yaml = readme_yaml_block(root)?; - let mut declared = Vec::new(); - let mut in_members = false; - for line in &yaml { - if in_members { - if let Some(item) = line.trim().strip_prefix("- ") { - declared.push(item.trim().to_string()); - } else { - in_members = false; - } - } - if line.trim() == "workspace_members:" { - in_members = true; - } - } - if actual == declared { +/// The workspace members in the root manifest match the members the README's +/// phase declaration states. +pub(crate) fn check_workspace_members(snapshot: &RepositorySnapshot) -> Result<(), String> { + let manifest = snapshot + .cargo() + .document(MANIFEST_FILE) + .taken(MANIFEST_FILE)?; + let actual = strings_at(manifest, &["workspace", "members"]).taken("the workspace members")?; + let readme = snapshot + .markdown() + .document(&CanonicalPath::spelled(ROOT_README)) + .taken(ROOT_README)?; + let phase = phase_declaration(readme).taken("the README phase declaration")?; + if actual.as_slice() == phase.members() { Ok(()) } else { Err(format!( - "Cargo.toml members {actual:?} but README declares {declared:?}" + "{MANIFEST_FILE} members {actual:?} but README declares {:?}", + phase.members() )) } } -/// The root manifest declares the one lint wall and every member inherits it. -pub(crate) fn check_lint_wall(root: &Path) -> Result<(), String> { - let manifest = - fs::read_to_string(root.join("Cargo.toml")).map_err(|e| format!("Cargo.toml: {e}"))?; - if !manifest.contains("[workspace.lints.rust]") { +/// The root manifest declares the one lint wall and every package inherits it. +/// +/// Inheritance is a DECLARATION — `[lints] workspace = true` — so it is asked of +/// the decoded document rather than matched as text. A member carrying those +/// bytes inside a comment declares nothing, and used to pass. +/// +/// # Why the population is the TREE and not the members array +/// +/// The `[workspace] members` array is not the set of workspace packages, and +/// reading it as though it were left a hole exactly where the wall matters most. +/// Cargo's workspace holds the root package itself — which is never written in +/// that array and is the package the machine ships from — plus every path +/// dependency residing inside the workspace directory, listed or not. A law +/// iterating the array therefore said nothing about the root package: deleting +/// the root's own `[lints] workspace = true` took the machine crate out from +/// under the one wall this workspace declares, and this check printed PASS. +/// +/// So the population is DERIVED from the reading: every `Cargo.toml` in the tree +/// that declares a `[package]` table is a package of this repository, and every +/// one of them inherits the wall. A manifest declaring no package declares no +/// lints to inherit and is not in the population. A directory the workspace +/// `exclude`s stands outside the workspace by Cargo's own statement, so it +/// stands outside the wall too, and it is the ONLY exemption — read off that +/// declaration rather than off a list kept here. +/// +/// The members array is still read, for the one claim only it can make: a +/// listed member has to BE one of those packages. A member naming a directory +/// carrying no package manifest is a workspace that does not resolve, and a +/// derived population would report clean about it, because what is not there +/// declares nothing. +pub(crate) fn check_lint_wall(snapshot: &RepositorySnapshot) -> Result<(), String> { + let manifest = snapshot + .cargo() + .document(MANIFEST_FILE) + .taken(MANIFEST_FILE)?; + if declares_table(manifest, &["workspace", "lints", "rust"]).known() != Some(&Declaration::Yes) + { + return Err(format!( + "root {MANIFEST_FILE} has no [workspace.lints.rust] wall" + )); + } + let outside = excluded_directories(manifest)?; + let mut packages = Vec::new(); + let mut offenders = Vec::new(); + for (path, _) in snapshot.files().iter() { + if path.file_name() != MANIFEST_FILE { + continue; + } + let document = snapshot + .cargo() + .document(path.as_str()) + .taken(path.as_str())?; + if declares_table(document, &["package"]).known() != Some(&Declaration::Yes) { + continue; + } + let home = package_home(path.as_str()); + if outside.contains(&home) { + continue; + } + packages.push(home); + if declares_yes(document, &["lints", "workspace"]).known() != Some(&Declaration::Yes) { + offenders.push(format!("{path} does not inherit the lint wall")); + } + } + if packages.is_empty() { return Err(String::from( - "root Cargo.toml has no [workspace.lints.rust] wall", + "no package manifest was found: this denominator cannot be empty while the workspace \ + declares a wall for its members to inherit, so the reader is looking at the wrong tree", )); } - let members = bracket_list(&manifest, "members")?; - let mut missing = Vec::new(); - for member in members { - let path = root.join(&member).join("Cargo.toml"); - let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - if !text.contains("[lints]\nworkspace = true") { - missing.push(member); + for member in strings_at(manifest, &["workspace", "members"]).taken("the workspace members")? { + if !packages.contains(&member) { + offenders.push(format!( + "the members array lists `{member}`, which declares no package manifest this \ + reading found" + )); } } - if missing.is_empty() { + if offenders.is_empty() { Ok(()) } else { - Err(format!("members not inheriting the lint wall: {missing:?}")) + Err(offenders.join("; ")) + } +} + +/// The directory one package manifest stands in, as this repository spells a +/// member: the root package's home is the root, spelled the way the members +/// array would spell it if it named one. +fn package_home(manifest: &str) -> String { + match manifest.rsplit_once('/') { + Some((directory, _)) => directory.to_owned(), + None => String::from(ROOT_PACKAGE_HOME), + } +} + +/// The directories the workspace declares OUTSIDE itself. +/// +/// A workspace stating no `exclude` excludes nothing, and that is Cargo's own +/// declaration rather than a reading that failed — the two are separated here, +/// and only the second refuses. Nothing is defaulted: an absent key is a +/// statement, and a key nobody could read is not. +fn excluded_directories(manifest: &toml::Table) -> Result, String> { + match strings_at(manifest, &["workspace", "exclude"]) { + Read::Known(listed) => Ok(listed), + Read::DeclaredAbsent(_) => Ok(Vec::new()), + Read::Unreadable(failure) => Err(format!( + "the workspace exclude list could not be read: {failure}" + )), } } @@ -119,7 +225,7 @@ mod tests { use super::{check_lint_wall, check_toolchain_pin, check_workspace_members}; use crate::checks::scratch::Scratch; - /// A README carrying the fenced yaml block the joins read. + /// A README carrying the fenced data block the joins read. const FIXTURE_README: &str = "# Fixture\n\n```yaml\nphase: architecture-closure\n\ toolchain: \"1.97.1\"\nworkspace_members:\n - one\n - two\n\ ```\n"; @@ -131,7 +237,7 @@ mod tests { /// own — and the last case reverses the exact state this join was written /// for, where the pin said 1.97.1 while two files said 1.97. #[test] - fn a_floor_that_drifts_from_the_pin_is_a_violation() { + fn a_floor_that_drifts_from_the_pin_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("toolchain-pin"); scratch.write("README.md", FIXTURE_README); scratch.write("rust-toolchain.toml", "[toolchain]\nchannel = \"1.97.1\"\n"); @@ -140,10 +246,10 @@ mod tests { "[workspace.package]\nrust-version = \"1.97.1\"\n", ); scratch.write("clippy.toml", "msrv = \"1.97.1\"\n"); - assert!(check_toolchain_pin(scratch.root()).is_ok()); + assert!(check_toolchain_pin(&scratch.read()?).is_ok()); scratch.write("rust-toolchain.toml", "[toolchain]\nchannel = \"1.98.0\"\n"); - let drifted_readme = check_toolchain_pin(scratch.root()); + let drifted_readme = check_toolchain_pin(&scratch.read()?); assert!(drifted_readme.is_err_and( |reason| reason.contains("1.98.0") && reason.contains("README declares 1.97.1") )); @@ -153,7 +259,7 @@ mod tests { "Cargo.toml", "[workspace.package]\nrust-version = \"1.97\"\n", ); - let drifted_floor = check_toolchain_pin(scratch.root()); + let drifted_floor = check_toolchain_pin(&scratch.read()?); assert!( drifted_floor.is_err_and(|reason| reason.contains("Cargo.toml rust-version is 1.97")) ); @@ -163,32 +269,34 @@ mod tests { "[workspace.package]\nrust-version = \"1.97.1\"\n", ); scratch.write("clippy.toml", "msrv = \"1.97\"\n"); - let drifted_suggestions = check_toolchain_pin(scratch.root()); + let drifted_suggestions = check_toolchain_pin(&scratch.read()?); assert!( drifted_suggestions.is_err_and(|reason| reason.contains("clippy.toml msrv is 1.97")) ); + Ok(()) } /// Planted reversal: a workspace member the README does not declare, in /// both directions — a member added to the manifest alone, and one removed /// from the manifest while the README still lists it. #[test] - fn a_member_set_that_drifts_from_the_readme_is_a_violation() { + fn a_member_set_that_drifts_from_the_readme_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("workspace-members"); scratch.write("README.md", FIXTURE_README); scratch.write("Cargo.toml", "[workspace]\nmembers = [\"one\", \"two\"]\n"); - assert!(check_workspace_members(scratch.root()).is_ok()); + assert!(check_workspace_members(&scratch.read()?).is_ok()); scratch.write( "Cargo.toml", "[workspace]\nmembers = [\"one\", \"two\", \"three\"]\n", ); - let added = check_workspace_members(scratch.root()); + let added = check_workspace_members(&scratch.read()?); assert!(added.is_err_and(|reason| reason.contains("three"))); scratch.write("Cargo.toml", "[workspace]\nmembers = [\"one\"]\n"); - let removed = check_workspace_members(scratch.root()); + let removed = check_workspace_members(&scratch.read()?); assert!(removed.is_err_and(|reason| reason.contains("two"))); + Ok(()) } /// Planted reversal: a member that does not inherit the lint wall, and @@ -196,7 +304,7 @@ mod tests { /// failures — one member walking out, and the wall never existing — and the /// check names them apart. #[test] - fn a_member_outside_the_lint_wall_is_a_violation() { + fn a_member_outside_the_lint_wall_is_a_violation() -> Result<(), String> { let scratch = Scratch::named("lint-wall"); let inheriting = "[package]\nname = \"member\"\n\n[lints]\nworkspace = true\n"; scratch.write( @@ -206,15 +314,127 @@ mod tests { ); scratch.write("one/Cargo.toml", inheriting); scratch.write("two/Cargo.toml", inheriting); - assert!(check_lint_wall(scratch.root()).is_ok()); + assert!(check_lint_wall(&scratch.read()?).is_ok()); scratch.write("two/Cargo.toml", "[package]\nname = \"member\"\n"); - let escaped = check_lint_wall(scratch.root()); + let escaped = check_lint_wall(&scratch.read()?); assert!(escaped.is_err_and(|reason| reason.contains("two") && !reason.contains("\"one\""))); scratch.write("two/Cargo.toml", inheriting); scratch.write("Cargo.toml", "[workspace]\nmembers = [\"one\", \"two\"]\n"); - let wall_free = check_lint_wall(scratch.root()); + let wall_free = check_lint_wall(&scratch.read()?); assert!(wall_free.is_err_and(|reason| reason.contains("no [workspace.lints.rust] wall"))); + Ok(()) + } + + /// Planted reversal: the ROOT package outside the wall it declares. + /// + /// The root package is a workspace member and is never written in the + /// members array, so a law iterating that array said nothing about it. This + /// is the machine's own crate: stripping its `[lints] workspace = true` + /// builds `threadpak` itself with no wall at all, and the check printed PASS + /// over it because the array it read still listed six members that inherit. + #[test] + fn a_root_package_outside_the_lint_wall_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("lint-wall-root"); + let inheriting = "[package]\nname = \"member\"\n\n[lints]\nworkspace = true\n"; + let root = "[package]\nname = \"machine\"\n\n[lints]\nworkspace = true\n\n\ + [workspace]\nmembers = [\"one\"]\n\n[workspace.lints.rust]\n\ + warnings = { level = \"deny\", priority = -1 }\n"; + scratch.write("Cargo.toml", root); + scratch.write("one/Cargo.toml", inheriting); + assert!(check_lint_wall(&scratch.read()?).is_ok()); + + scratch.write( + "Cargo.toml", + "[package]\nname = \"machine\"\n\n[workspace]\nmembers = [\"one\"]\n\n\ + [workspace.lints.rust]\nwarnings = { level = \"deny\", priority = -1 }\n", + ); + let stripped = check_lint_wall(&scratch.read()?); + assert!( + stripped.is_err_and(|reason| reason.contains("Cargo.toml does not inherit") + && !reason.contains("one/Cargo.toml")), + "the root package's own inheritance is not read" + ); + Ok(()) + } + + /// A package the members array never lists is still a package, and the wall + /// is still its wall. + /// + /// Cargo makes a path dependency residing inside the workspace directory a + /// member whether or not anybody wrote it down, so a population read off the + /// array is a population with a door in it. The population is the tree, and + /// the one exemption is the workspace's own `exclude` — read off that + /// declaration rather than off a list kept in this file. + #[test] + fn a_package_the_array_never_lists_is_still_in_the_population() -> Result<(), String> { + let scratch = Scratch::named("lint-wall-unlisted"); + scratch.write( + "Cargo.toml", + "[workspace]\nmembers = [\"one\"]\nexclude = [\"outside\"]\n\n\ + [workspace.lints.rust]\nwarnings = { level = \"deny\", priority = -1 }\n", + ); + scratch.write( + "one/Cargo.toml", + "[package]\nname = \"member\"\n\n[lints]\nworkspace = true\n", + ); + scratch.write("outside/Cargo.toml", "[package]\nname = \"excluded\"\n"); + assert!(check_lint_wall(&scratch.read()?).is_ok()); + + scratch.write("helpers/Cargo.toml", "[package]\nname = \"unlisted\"\n"); + let unlisted = check_lint_wall(&scratch.read()?); + assert!( + unlisted.is_err_and(|reason| reason.contains("helpers/Cargo.toml") + && !reason.contains("outside/Cargo.toml")), + "an unlisted package escaped the wall, or an excluded one was dragged under it" + ); + Ok(()) + } + + /// A member the array lists and the tree does not carry is a workspace that + /// does not resolve, and a derived population would report clean about it. + #[test] + fn a_member_the_tree_does_not_carry_is_a_violation() -> Result<(), String> { + let scratch = Scratch::named("lint-wall-ghost"); + scratch.write( + "Cargo.toml", + "[workspace]\nmembers = [\"one\", \"ghost\"]\n\n[workspace.lints.rust]\n\ + warnings = { level = \"deny\", priority = -1 }\n", + ); + scratch.write( + "one/Cargo.toml", + "[package]\nname = \"member\"\n\n[lints]\nworkspace = true\n", + ); + let found = check_lint_wall(&scratch.read()?); + assert!(found.is_err_and(|reason| reason.contains("`ghost`"))); + Ok(()) + } + + /// Planted reversal: a member carrying the inheritance bytes inside a + /// COMMENT. + /// + /// The substring reader this replaced passed it. The member declares no + /// `[lints]` table at all, so it inherits nothing and builds outside the one + /// wall this workspace declares — while a law about that inheritance read + /// clean, because the characters it was matching were in the file. + #[test] + fn inheritance_written_in_a_comment_inherits_nothing() -> Result<(), String> { + let scratch = Scratch::named("lint-wall-comment"); + scratch.write( + "Cargo.toml", + "[workspace]\nmembers = [\"one\"]\n\n[workspace.lints.rust]\n\ + warnings = { level = \"deny\", priority = -1 }\n", + ); + scratch.write( + "one/Cargo.toml", + "[package]\nname = \"member\"\n# [lints]\n# workspace = true\n", + ); + let found = check_lint_wall(&scratch.read()?); + assert!( + found.is_err_and(|reason| reason.contains("one")), + "a member that declares no inheritance passed on the strength of a comment" + ); + Ok(()) } } diff --git a/xtask/src/checks/vocabulary.rs b/xtask/src/checks/vocabulary.rs index 89354f1..ecf001c 100644 --- a/xtask/src/checks/vocabulary.rs +++ b/xtask/src/checks/vocabulary.rs @@ -6,11 +6,8 @@ //! plural, a `camelCase` seam — and both have to say so out loud, because a //! word-scan that only reads prose is a word-scan somebody will route around. -use std::fs; -use std::path::Path; - -use crate::repository::walk::{ - JUDGE_DIRECTORY, TOOLING_DIRECTORY, relative_slash_path, visit_files, +use crate::repository::snapshot::{ + JUDGE_DIRECTORY, MACHINE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY, }; /// The construction-lifecycle vocabulary this gate enforces, in prose and in @@ -86,7 +83,7 @@ const BANNED_VOCABULARY_ALLOWLIST: [(&str, &str, &str); 3] = [ /// No personal name appears in any repository file — role terms only. The /// banned spellings are assembled from bytes so this checker never contains /// what it forbids. -pub(crate) fn check_no_personal_names(root: &Path) -> Result<(), String> { +pub(crate) fn check_no_personal_names(snapshot: &RepositorySnapshot) -> Result<(), String> { let banned: [Vec; 2] = [ vec![0x65, 0x61, 0x73, 0x73, 0x61], vec![0x61, 0x79, 0x6f, 0x75, 0x62], @@ -96,17 +93,12 @@ pub(crate) fn check_no_personal_names(root: &Path) -> Result<(), String> { .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) .collect(); let mut offenders = Vec::new(); - visit_files(root, &mut |path| { - let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - let text = String::from_utf8_lossy(&bytes).to_lowercase(); - for name in &banned { - if text.contains(name.as_str()) { - offenders.push(path.display().to_string()); - break; - } + for (path, fact) in snapshot.files().iter() { + let text = fact.text().required(path.as_str())?.to_lowercase(); + if banned.iter().any(|name| text.contains(name.as_str())) { + offenders.push(path.to_string()); } - Ok(()) - })?; + } if offenders.is_empty() { Ok(()) } else { @@ -151,27 +143,33 @@ pub(crate) fn check_no_personal_names(root: &Path) -> Result<(), String> { /// forbids, and neither are the two working-law files, which state the ban /// itself; a banned word could stand in any of them and this gate would not see /// it. -pub(crate) fn check_banned_vocabulary(root: &Path) -> Result<(), String> { +pub(crate) fn check_banned_vocabulary(snapshot: &RepositorySnapshot) -> Result<(), String> { let mut offenders = Vec::new(); let mut read: Vec<(String, String)> = Vec::new(); - let mut inspect = |path: &Path| -> Result<(), String> { - let scanned = path - .extension() - .is_some_and(|extension| extension == "rs" || extension == "md"); - if !scanned { - return Ok(()); + let mut scanned: Vec<(String, String)> = Vec::new(); + for tree in [MACHINE_DIRECTORY, TOOLING_DIRECTORY, JUDGE_DIRECTORY] { + for (path, fact) in snapshot.files().under(tree) { + if !path.extension_is("rs") && !path.extension_is("md") { + continue; + } + scanned.push(( + path.to_string(), + fact.text().required(path.as_str())?.to_owned(), + )); } - let relative = relative_slash_path(root, path); - let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - let text = String::from_utf8_lossy(&bytes).into_owned(); + } + scanned.push(( + String::from(ROOT_README), + snapshot + .files() + .text(ROOT_README) + .taken(ROOT_README)? + .to_owned(), + )); + for (relative, text) in scanned { offenders.extend(banned_vocabulary_offences(&relative, &text)); read.push((relative, text)); - Ok(()) - }; - visit_files(&root.join("src"), &mut inspect)?; - visit_files(&root.join(TOOLING_DIRECTORY), &mut inspect)?; - visit_files(&root.join(JUDGE_DIRECTORY), &mut inspect)?; - inspect(&root.join("README.md"))?; + } // The allowlist is joined against the same scan: every allowance has to // still be excusing something. offenders.extend(stale_allowlist_offences(&read)); @@ -185,6 +183,9 @@ pub(crate) fn check_banned_vocabulary(root: &Path) -> Result<(), String> { } } +/// The one document outside the three trees that this gate scans. +const ROOT_README: &str = "README.md"; + /// Every allowlist entry whose named file no longer spells the word it excuses, /// one offence per stale entry. /// @@ -370,9 +371,7 @@ mod tests { BANNED_VOCABULARY_ALLOWLIST, banned_vocabulary_offences, banned_words_in, stale_allowlist_offences, }; - use crate::repository::walk::repo_root; - use std::fs; - use std::path::PathBuf; + use crate::repository::snapshot::repository_snapshot; /// Planted reversal: the term smuggled into a `camelCase` identifier, where /// no whole-word scan of the text would ever find it. @@ -485,18 +484,17 @@ mod tests { /// The real allowlist holds: every entry still excuses a word its named /// file spells, read through the ban's own scan. #[test] - fn the_real_allowlist_still_excuses_something() { - let root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let scanned: Vec<(String, String)> = BANNED_VOCABULARY_ALLOWLIST - .iter() - .map(|(file, _, _)| { - ( - (*file).to_string(), - fs::read_to_string(root.join(file)).unwrap_or_default(), - ) - }) - .collect(); + fn the_real_allowlist_still_excuses_something() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let mut scanned: Vec<(String, String)> = Vec::new(); + for (file, _, _) in BANNED_VOCABULARY_ALLOWLIST { + scanned.push(( + String::from(file), + snapshot.files().text(file).taken(file)?.to_owned(), + )); + } let found = stale_allowlist_offences(&scanned); assert!(found.is_empty(), "{found:?}"); + Ok(()) } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cefe709..bb82562 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,10 +2,10 @@ //! //! Two commands, and the second contains the first. //! -//! `cargo xtask check` runs every day-zero repository law and reports each -//! result; any broken law fails the run. Checks grow one at a time as each -//! written rule gains something to enforce — the repository never carries a rule -//! that nothing checks. +//! `cargo xtask check` reads the repository ONCE and runs every day-zero +//! repository law over that one reading, reporting each result; any broken law +//! fails the run. Checks grow one at a time as each written rule gains something +//! to enforce — the repository never carries a rule that nothing checks. //! //! `cargo xtask qualify` runs the complete entry bar, and the ordered stage //! table in [`qualification`] is the only definition of what that bar is — @@ -14,16 +14,16 @@ //! bar and the only spelling of it, so the road a hosted runner takes and the //! road a working machine takes cannot differ. //! -//! This file is the shell and nothing else. It resolves the command, holds the -//! one table that names every law beside the function that checks it, and runs -//! that table in order. The laws live in [`checks`]; the reading they do lives -//! in [`repository`]; the ordered battery `qualify` runs lives in -//! [`qualification`], which is handed the law table's runner rather than -//! reaching back for it. Keeping the table alone here is what makes the -//! registered set readable in one screen: the array below is the roster, its -//! length is the only statement of how many laws there are, and adding a law -//! is one more line in it — so a law added without a name, or a name -//! registered twice, is visible at a glance rather than buried among the +//! This file is the shell and nothing else. It resolves the command, builds the +//! one reading, holds the one table that names every law beside the function +//! that checks it, and runs that table in order. The laws live in [`checks`]; +//! the reading they all stand on lives in [`repository`]; the ordered battery +//! `qualify` runs lives in [`qualification`], which is handed the law table's +//! runner rather than reaching back for it. Keeping the table alone here is what +//! makes the registered set readable in one screen: the array below is the +//! roster, its length is the only statement of how many laws there are, and +//! adding a law is one more line in it — so a law added without a name, or a +//! name registered twice, is visible at a glance rather than buried among the //! checks themselves. mod checks; @@ -33,27 +33,32 @@ mod qualification; use std::error::Error; use std::path::Path; +use crate::checks::alarms::check_alarm_artifacts; use crate::checks::coupling::check_collection_bodies_are_coupled; use crate::checks::dependency::check_no_core_tooling_edge; use crate::checks::hygiene::{ check_lf_and_no_symlinks, check_no_python, check_underscore_fields_are_phantom, }; -use crate::checks::mint::check_refusal_mints_are_inside_the_plane; use crate::checks::obligations::check_obligations_join; use crate::checks::parity::check_agents_claude_parity; use crate::checks::placement::{check_band_map, check_tooling_module_order}; -use crate::checks::seal::check_stamped_guards_seal_their_position; +use crate::checks::positivity::check_inhabitant_promising_limits; +use crate::checks::seat::check_seat_modules_carry_nothing_else; use crate::checks::supply_chain::check_dependency_gate_artifacts; use crate::checks::toolchain::{check_lint_wall, check_toolchain_pin, check_workspace_members}; use crate::checks::vocabulary::{check_banned_vocabulary, check_no_personal_names}; +use crate::repository::snapshot::{RepositorySnapshot, repo_root}; use crate::repository::types::Check; -use crate::repository::walk::repo_root; + +/// The command a bare `cargo xtask` means. +const DEFAULT_COMMAND: &str = "check"; fn main() -> Result<(), Box> { let root = repo_root()?; - let command = std::env::args() - .nth(1) - .unwrap_or_else(|| String::from("check")); + let command = match std::env::args().nth(1) { + Some(named) => named, + None => String::from(DEFAULT_COMMAND), + }; match command.as_str() { "check" => run_checks(&root), "qualify" => qualification::qualify(&root, run_checks), @@ -61,9 +66,43 @@ fn main() -> Result<(), Box> { } } -/// Runs every repository law, printing one PASS or FAIL line per law. +/// Reads the repository once and runs every repository law over that reading, +/// printing one PASS or FAIL line per law. +/// +/// The reading comes first and is shared, which is the whole of the typed +/// repository model: no law walks the tree, opens a file, or starts a process, +/// so two laws cannot be judging two different trees. The run opens by naming +/// what it read — how many files, and WHETHER those files are a committed tree — +/// because a verdict that cannot be attached to a tree is a verdict about +/// nothing in particular, and this campaign has already produced one false green +/// from a restore that preserved a modification time. +/// +/// The opening line no longer names a commit it has not established. It used to: +/// the reading walked the disk and then asked git what `HEAD` was, and printed +/// the two side by side as though one were about the other, so every run on a +/// dirty checkout stated a commit-bound result it had never bound. The binding +/// is established around the read now, and where there is none the sentence says +/// so instead of naming a commit anyway. fn run_checks(root: &Path) -> Result<(), Box> { - let checks: [Check; 17] = [ + let snapshot = RepositorySnapshot::read(root)?; + println!( + "read {} files {}", + snapshot.files().count(), + snapshot.binding() + ); + // EIGHTEEN, and the number is decided here rather than counted from the + // lines below, because this array's length is the only statement of how many + // repository laws there are and a wrong one is `E0308` rather than a law + // that quietly stopped running. Where it came from, exactly: the two lanes + // this merge joins branched from a roster of SEVENTEEN. One lane made a + // defect class unrepresentable and retired the two readers that had been + // re-implementing name resolution to hunt it — the refusal-mint law and the + // stamped-guard law — replacing both with the one syntax-only law + // `seat-modules-carry-nothing-else`, which is sixteen. Two legs the lanes + // named honestly rather than shipping around land here: + // `alarm-artifacts-are-present-and-distinct` and + // `inhabitant-promising-limits-are-witnessed`. Sixteen and two is eighteen. + let checks: [Check; 18] = [ ("agents-claude-parity", check_agents_claude_parity), ("lf-and-no-symlinks", check_lf_and_no_symlinks), ("no-python", check_no_python), @@ -75,6 +114,10 @@ fn run_checks(root: &Path) -> Result<(), Box> { "dependency-gate-artifacts-are-present-and-distinct", check_dependency_gate_artifacts, ), + ( + "alarm-artifacts-are-present-and-distinct", + check_alarm_artifacts, + ), ( "underscore-fields-are-phantom", check_underscore_fields_are_phantom, @@ -87,19 +130,19 @@ fn run_checks(root: &Path) -> Result<(), Box> { check_collection_bodies_are_coupled, ), ( - "refusal-mints-are-inside-the-plane", - check_refusal_mints_are_inside_the_plane, + "seat-modules-carry-nothing-else", + check_seat_modules_carry_nothing_else, ), ( - "stamped-guards-seal-their-position", - check_stamped_guards_seal_their_position, + "inhabitant-promising-limits-are-witnessed", + check_inhabitant_promising_limits, ), ("no-personal-names", check_no_personal_names), ("banned-vocabulary", check_banned_vocabulary), ]; let mut failures = Vec::new(); for (name, check) in checks { - match check(root) { + match check(&snapshot) { Ok(()) => println!("PASS {name}"), Err(reason) => { println!("FAIL {name}: {reason}"); diff --git a/xtask/src/qualification.rs b/xtask/src/qualification.rs index 36d8252..bf084f9 100644 --- a/xtask/src/qualification.rs +++ b/xtask/src/qualification.rs @@ -55,10 +55,11 @@ //! and cost a rebuild. use std::error::Error; -use std::ffi::OsString; use std::path::Path; use std::process::{Command, Stdio}; +use crate::repository::snapshot::cargo_binary; + /// One qualification stage: what the log calls it, and the work it is. struct Stage { /// The name printed when the stage opens and when it settles. @@ -291,17 +292,6 @@ fn dirty_entries(listing: &str) -> Vec<&str> { listing.lines().filter(|line| !line.is_empty()).collect() } -/// The cargo binary a stage is spawned with. -/// -/// Cargo sets `CARGO` for every process it starts, so a nested invocation -/// reaches the exact binary that started this one — the pinned toolchain's -/// cargo, not whatever a machine's search path resolves today. The fallback -/// covers the case where the xtask binary is run directly, where no pin has -/// been resolved and the search path is all there is. -fn cargo_binary() -> OsString { - std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")) -} - /// Planted reversals for the closing stage's verdict. /// /// The verdict is pure over git's listing, so every case below is a fixture diff --git a/xtask/src/repository/cargo.rs b/xtask/src/repository/cargo.rs new file mode 100644 index 0000000..b6b659d --- /dev/null +++ b/xtask/src/repository/cargo.rs @@ -0,0 +1,862 @@ +//! Cargo's own answers about Cargo. +//! +//! Two authorities, two questions, and keeping them apart is the whole of this +//! module. +//! +//! **What a manifest DECLARES** is a question about TOML, and the `toml` +//! decoder answers it. Every spelling Cargo admits — a dotted key, a quoted key, +//! a bracketed-string header, an escaped key, a literal string, a multi-line +//! string, a four-quote terminator, a unicode escape, an inline table, a key +//! carrying `=`, a comment after a header — is one document to that decoder, so +//! a reader standing on it recognizes a spelling nobody thought of. +//! +//! **What Cargo NORMALIZES** is a different question, and only cargo answers it: +//! `cargo metadata --locked --format-version 1` reports, under +//! `packages[].dependencies`, each package's manifest dependency DECLARATIONS +//! after cargo has normalized them — workspace inheritance applied, package +//! identity settled, a rename separated from the key it is written at, the edge +//! kind spelled, a platform predicate spelled. The format version is pinned in +//! the invocation because it is the machine-readable contract; `--locked` is +//! pinned because a run that repaired the lock file on its way past would be +//! reporting about a dependency set nobody chose. +//! +//! # Normalized declarations are not the resolved graph, and this reads the +//! # declarations +//! +//! Cargo reports the two separately and they are not the same population. +//! `packages[].dependencies` is what the manifests declare, normalized: it +//! carries an optional dependency no feature enables, a dependency conditioned +//! on a target this build is not for, and a dev edge no ordinary build takes. +//! `resolve.nodes[].deps` is the SELECTED graph — the edges one feature and +//! target selection actually activates. This module deserializes the first and +//! is deliberately silent about the second. +//! +//! That is the right subject for the one law standing on it. The topology rule +//! forbids an edge from existing at all, so a target-conditional edge and a +//! currently-inactive optional edge are exactly the cases it must catch, and +//! reading the selected graph would let either pass on a machine whose selection +//! happens not to activate it. Nothing here is a claim about the selected graph, +//! and `resolve.nodes` is the reading to add if a claim about it is ever +//! written — never a stricter version of this one. +//! +//! # What this replaced, and why the class rather than the site +//! +//! A line reader stood here. It cut a line at its first `=` and read the head as +//! a package name, so `threadpak-macroc.workspace = true` named the package +//! `threadpak-macroc.workspace`, which matched no law. Eleven distinct spellings +//! escaped it over one campaign, and each repair — a dotted-key pass, a +//! quoted-key pass, a literal-string pass, a comment-stripping pass — revealed +//! the next one, because a spelling admitted one at a time is a set with no last +//! member. The reader was not unlucky. It was in the wrong seat: a weaker reader +//! re-deriving what a stronger reader already owned. +//! +//! Nothing here recognizes a spelling. The decoder resolves the document and +//! this module reads key paths out of it. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde::Deserialize; + +use crate::repository::snapshot::{CanonicalFileMap, cargo_binary}; +use crate::repository::types::{AbsenceReason, CanonicalPath, Read, ReadFailure}; + +/// The manifest every Cargo package is declared in. +pub(crate) const MANIFEST_FILE: &str = "Cargo.toml"; + +/// The table a platform-conditional dependency table hangs beneath. +const TARGET_TABLE: &str = "target"; + +/// Everything the Cargo authorities established about this repository, read +/// once. +pub(crate) struct CargoSnapshot { + /// What cargo normalized the manifests to, or why nobody asked it. + normalized: Read, + /// Every `.toml` document in the tree, decoded by the decoder that owns + /// TOML. Keyed by canonical path, so no reader spells one twice. + documents: BTreeMap>, + /// Every dependency entry every committed manifest declares, in one list. + census: ManifestCensus, +} + +impl CargoSnapshot { + /// Reads every TOML document in the tree, takes the census off them, and + /// asks cargo what it resolved. + pub(crate) fn read(root: &Path, files: &CanonicalFileMap) -> Self { + let mut documents = BTreeMap::new(); + for (path, fact) in files.iter() { + if !path.extension_is("toml") { + continue; + } + documents.insert(path.clone(), decode(path, fact.text())); + } + let census = ManifestCensus::take(&documents); + Self { + normalized: ask_cargo(root, files), + documents, + census, + } + } + + /// What cargo normalized the manifests to, or why nobody asked it. + pub(crate) const fn normalized(&self) -> &Read { + &self.normalized + } + + /// One decoded TOML document, or the absence of the file that would carry + /// it. + pub(crate) fn document(&self, path: &str) -> Read<&toml::Table> { + match self.documents.get(&CanonicalPath::spelled(path)) { + Some(Read::Known(document)) => Read::Known(document), + Some(Read::DeclaredAbsent(reason)) => Read::DeclaredAbsent(*reason), + Some(Read::Unreadable(failure)) => Read::Unreadable(failure.clone()), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchPath), + } + } + + /// Every dependency entry every committed manifest declares. + pub(crate) const fn census(&self) -> &ManifestCensus { + &self.census + } +} + +/// One TOML text, decoded, with the failure carried where it did not decode. +fn decode(path: &CanonicalPath, text: &Read) -> Read { + match *text { + Read::Known(ref text) => match text.parse::() { + Ok(document) => Read::Known(document), + Err(error) => Read::Unreadable(ReadFailure::new(path.as_str(), &error.to_string())), + }, + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + } +} + +/// Which Cargo edge kind one declaration sits under. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum EdgeKind { + /// `[dependencies]` — the edge a build takes. + Ordinary, + /// `[dev-dependencies]` — the edge tests take, and still an edge. + Development, + /// `[build-dependencies]` — the edge a build script takes. + Build, +} + +impl EdgeKind { + /// The table this kind is declared in, as Cargo spells it. + const fn table(self) -> &'static str { + match self { + EdgeKind::Ordinary => "dependencies", + EdgeKind::Development => "dev-dependencies", + EdgeKind::Build => "build-dependencies", + } + } + + /// The kind `cargo metadata` reports one resolved edge under. Cargo states + /// nothing for an ordinary edge, so absence IS the ordinary kind here — a + /// fact of the reported format rather than a default this reader invented. + fn reported(kind: Option<&str>) -> Read { + match kind { + None => Read::Known(EdgeKind::Ordinary), + Some("dev") => Read::Known(EdgeKind::Development), + Some("build") => Read::Known(EdgeKind::Build), + Some(other) => Read::Unreadable(ReadFailure::new( + "cargo metadata dependency kind", + &format!("`{other}` is no edge kind this reader knows"), + )), + } + } +} + +impl fmt::Display for EdgeKind { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + out.write_str(self.table()) + } +} + +/// Every Cargo edge kind, in the order a census reports them. +const EDGE_KINDS: [EdgeKind; 3] = [EdgeKind::Ordinary, EdgeKind::Development, EdgeKind::Build]; + +/// One dependency entry, as one manifest DECLARES it. +/// +/// The four facts a topology law needs and nothing else: which edge kind the +/// entry sits under, the key it is written at, the package it names where it +/// renames one, and the path it points at where it declares one. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct DeclaredDependency { + /// The manifest that declares it. + manifest: CanonicalPath, + /// The edge kind it sits under. + kind: EdgeKind, + /// The key it is written at, which is the local name unless it renames. + key: String, + /// The package it names, where the entry states one. + package: Option, + /// The path it points at, where the entry states one. + path: Option, +} + +impl DeclaredDependency { + /// The manifest that declares it. + pub(crate) const fn manifest(&self) -> &CanonicalPath { + &self.manifest + } + + /// The package this entry resolves to: its `package = "…"` where it states + /// one, and its own key otherwise. That is Cargo's rule, and it is why a + /// rename hides nothing. + pub(crate) fn identity(&self) -> &str { + match self.package { + Some(ref named) => named, + None => &self.key, + } + } + + /// The path it points at, where the entry declares one. + pub(crate) fn path(&self) -> Option<&str> { + self.path.as_deref() + } +} + +impl fmt::Display for DeclaredDependency { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(out, "[{}] `{}`", self.kind, self.key) + } +} + +/// Every dependency entry every committed manifest declares. +/// +/// The census is taken ONCE, over every manifest at once, so no law can be +/// judging a different set of entries than another. A number that moves here +/// moved because the tree moved. +pub(crate) struct ManifestCensus(Vec); + +impl ManifestCensus { + /// The census of every decoded manifest, in manifest-path order and, within + /// one manifest, in edge-kind then key order. + fn take(documents: &BTreeMap>) -> Self { + let mut entries = Vec::new(); + for (path, document) in documents { + if path.file_name() != MANIFEST_FILE { + continue; + } + if let Read::Known(ref document) = *document { + entries.extend(dependency_declarations(path, document)); + } + } + Self(entries) + } + + /// Every entry one manifest declares. + pub(crate) fn of(&self, manifest: &str) -> Vec<&DeclaredDependency> { + let named = CanonicalPath::spelled(manifest); + self.0 + .iter() + .filter(|entry| *entry.manifest() == named) + .collect() + } +} + +/// Every dependency entry one decoded manifest declares. +/// +/// Nothing here recognizes a spelling. The decoder resolved the document, and +/// this reads three key paths out of it plus the same three beneath every +/// `target.` table — which is what a platform-conditional edge is, and +/// which is why one is read exactly like the unconditional edge it conditions. +pub(crate) fn dependency_declarations( + manifest: &CanonicalPath, + document: &toml::Table, +) -> Vec { + let mut declared = Vec::new(); + read_edge_tables(manifest, document, &mut declared); + if let Some(toml::Value::Table(targets)) = document.get(TARGET_TABLE) { + for conditioned in targets.values() { + if let toml::Value::Table(conditioned) = conditioned { + read_edge_tables(manifest, conditioned, &mut declared); + } + } + } + declared +} + +/// The three edge tables of one table, whether that table is the document root +/// or one `target.` beneath it. +fn read_edge_tables( + manifest: &CanonicalPath, + table: &toml::Table, + into: &mut Vec, +) { + for kind in EDGE_KINDS { + if let Some(toml::Value::Table(entries)) = table.get(kind.table()) { + for (key, value) in entries { + into.push(DeclaredDependency { + manifest: manifest.clone(), + kind, + key: key.clone(), + package: field(value, "package"), + path: field(value, "path"), + }); + } + } + } +} + +/// One string field of one entry's value, where the entry states a table at +/// all. A version-only entry — `serde = "1"` — states neither field, which is +/// the declaration it is rather than an absence anybody has to interpret. +fn field(value: &toml::Value, named: &str) -> Option { + let toml::Value::Table(fields) = value else { + return None; + }; + if let Some(toml::Value::String(spelled)) = fields.get(named) { + Some(spelled.clone()) + } else { + None + } +} + +/// The value one key path names, or the declared absence of it. +fn value_at<'document>( + document: &'document toml::Table, + key_path: &[&str], +) -> Read<&'document toml::Value> { + let Some((last, leading)) = key_path.split_last() else { + return Read::DeclaredAbsent(AbsenceReason::NoSuchKey); + }; + let mut table = document; + for segment in leading { + let Some(toml::Value::Table(inner)) = table.get(*segment) else { + return Read::DeclaredAbsent(AbsenceReason::NoSuchKey); + }; + table = inner; + } + match table.get(*last) { + Some(found) => Read::Known(found), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchKey), + } +} + +/// The word one TOML value's kind is named by, for a message about a document +/// that states the wrong kind at a key. +fn kind_word(value: &toml::Value) -> &'static str { + match *value { + toml::Value::String(_) => "string", + toml::Value::Integer(_) => "integer", + toml::Value::Float(_) => "float", + toml::Value::Boolean(_) => "boolean", + toml::Value::Datetime(_) => "datetime", + toml::Value::Array(_) => "array", + toml::Value::Table(_) => "table", + } +} + +/// The string one key path states. +pub(crate) fn string_at(document: &toml::Table, key_path: &[&str]) -> Read { + match value_at(document, key_path) { + Read::Known(toml::Value::String(spelled)) => Read::Known(spelled.clone()), + Read::Known(other) => Read::Unreadable(ReadFailure::new( + &key_path.join("."), + &format!("states a {} where a string was declared", kind_word(other)), + )), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } +} + +/// The list of strings one key path states. +pub(crate) fn strings_at(document: &toml::Table, key_path: &[&str]) -> Read> { + match value_at(document, key_path) { + Read::Known(toml::Value::Array(items)) => { + let mut listed = Vec::new(); + for item in items { + let toml::Value::String(spelled) = item else { + return Read::Unreadable(ReadFailure::new( + &key_path.join("."), + &format!("lists a {} where a string was declared", kind_word(item)), + )); + }; + listed.push(spelled.clone()); + } + Read::Known(listed) + } + Read::Known(other) => Read::Unreadable(ReadFailure::new( + &key_path.join("."), + &format!("states a {} where a list was declared", kind_word(other)), + )), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } +} + +/// Whether one key path states `true`. +pub(crate) fn declares_yes(document: &toml::Table, key_path: &[&str]) -> Read { + match value_at(document, key_path) { + Read::Known(toml::Value::Boolean(stated)) => Read::Known(if *stated { + Declaration::Yes + } else { + Declaration::No + }), + Read::Known(other) => Read::Unreadable(ReadFailure::new( + &key_path.join("."), + &format!("states a {} where a boolean was declared", kind_word(other)), + )), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } +} + +/// What one boolean key states, as a name rather than as a bare `true`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Declaration { + /// The key states `true`. + Yes, + /// The key states `false`. + No, +} + +/// The table one key path states. +/// +/// The twin of [`string_at`] for the readings whose subject is a whole table +/// rather than one value in it — a configuration profile, say, which is only +/// comparable to another profile as a table. A key path stating something that +/// is not a table is UNREADABLE rather than absent, for the reason every +/// accessor here says so: a caller told "no such table" about a key stating a +/// string would go looking for a key nobody deleted. +pub(crate) fn table_at<'document>( + document: &'document toml::Table, + key_path: &[&str], +) -> Read<&'document toml::Table> { + match value_at(document, key_path) { + Read::Known(toml::Value::Table(stated)) => Read::Known(stated), + Read::Known(other) => Read::Unreadable(ReadFailure::new( + &key_path.join("."), + &format!("states a {} where a table was declared", kind_word(other)), + )), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } +} + +/// Whether one key path names a table at all. +pub(crate) fn declares_table(document: &toml::Table, key_path: &[&str]) -> Read { + match value_at(document, key_path) { + Read::Known(toml::Value::Table(_)) => Read::Known(Declaration::Yes), + Read::Known(_) => Read::Known(Declaration::No), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } +} + +/// What `cargo metadata` reported about this workspace's manifests. +/// +/// The fields are exactly the ones a law reads. `cargo metadata` reports a great +/// deal more — `resolve` among it — and every field named here is one this +/// repository has a reader for; a field carried because it was available would +/// be an inventory nobody joins. +#[derive(Debug, Deserialize)] +pub(crate) struct NormalizedWorkspace { + /// Every package cargo reported, workspace members included. This is the + /// manifest census as cargo normalized it, and not the selected graph — see + /// this module's own documentation for why that is the subject wanted here. + packages: Vec, +} + +impl NormalizedWorkspace { + /// The package cargo reported under one name, or the declared absence of + /// it. + pub(crate) fn package(&self, named: &str) -> Read<&NormalizedPackage> { + match self.packages.iter().find(|package| package.name == named) { + Some(found) => Read::Known(found), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchKey), + } + } +} + +/// One package's manifest, as cargo normalized it. +#[derive(Debug, Deserialize)] +pub(crate) struct NormalizedPackage { + /// The package name, which is the identity a law judges. + name: String, + /// Every dependency this package's manifest DECLARES, of every kind, + /// whether or not a build selects it. + dependencies: Vec, +} + +impl NormalizedPackage { + /// Every dependency this package's manifest declares. + pub(crate) fn declarations(&self) -> &[NormalizedDependency] { + &self.dependencies + } +} + +/// One dependency declaration, as cargo normalized it. +/// +/// Normalized rather than resolved: cargo has applied workspace inheritance, +/// settled the package identity, and separated a rename from the key it is +/// written at. Whether a build SELECTS this edge is a question about +/// `resolve.nodes`, which nothing here reads. +#[derive(Debug, Deserialize)] +pub(crate) struct NormalizedDependency { + /// The PACKAGE the declaration names. Cargo reports the package rather than + /// the key, so a rename is already settled here. + name: String, + /// The key the declaring manifest wrote, where it renamed the package. + rename: Option, + /// The edge kind, as cargo spells it: nothing for an ordinary edge. + kind: Option, + /// The platform predicate the declaration is conditioned on, where it is + /// conditioned at all. + target: Option, +} + +impl NormalizedDependency { + /// The package the declaration names. + pub(crate) fn package(&self) -> &str { + &self.name + } + + /// The key the declaring manifest wrote it at. + pub(crate) fn key(&self) -> &str { + match self.rename { + Some(ref renamed) => renamed, + None => &self.name, + } + } + + /// The edge kind, or the failure of reading one cargo spelled a way this + /// reader does not know. + pub(crate) fn kind(&self) -> Read { + EdgeKind::reported(self.kind.as_deref()) + } + + /// The platform predicate, where the declaration is conditioned. + pub(crate) fn target(&self) -> Option<&str> { + self.target.as_deref() + } +} + +/// Asks cargo what it normalized the manifests to, or states why nobody asked. +/// +/// A root declaring no manifest is not a workspace, and saying so is a +/// DECLARED absence rather than an empty report: an empty report would answer +/// "the core reaches no tooling" about a tree cargo never opened. +fn ask_cargo(root: &Path, files: &CanonicalFileMap) -> Read { + if files.get(MANIFEST_FILE).is_none() { + return Read::DeclaredAbsent(AbsenceReason::NotAWorkspaceCheckout); + } + let output = Command::new(cargo_binary()) + .current_dir(root) + .args([ + "metadata", + "--locked", + "--format-version", + "1", + "--manifest-path", + ]) + .arg(root.join(MANIFEST_FILE)) + .stderr(Stdio::piped()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) => { + return Read::Unreadable(ReadFailure::new("cargo metadata", &error.to_string())); + } + }; + if !output.status.success() { + return Read::Unreadable(ReadFailure::new( + "cargo metadata", + String::from_utf8_lossy(&output.stderr).trim(), + )); + } + match serde_json::from_slice::(&output.stdout) { + Ok(reported) => Read::Known(reported), + Err(error) => Read::Unreadable(ReadFailure::new( + "cargo metadata --format-version 1", + &error.to_string(), + )), + } +} + +/// Planted reversals for the reader that replaced eleven passes over eleven +/// spellings. +/// +/// The claim under test is not "this reader knows these spellings". It is that +/// there are no spellings to know: the decoder resolves the document and this +/// reader reads key paths, so every way Cargo admits of writing one declaration +/// arrives as one declaration. Every case below is a fixture string — the reader +/// is proven against text, never against the tree it guards. +#[cfg(test)] +mod tests { + use super::{DeclaredDependency, EdgeKind, dependency_declarations, string_at, strings_at}; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::{CanonicalPath, Read}; + + /// The entries one fixture manifest declares, decoded by the decoder that + /// owns TOML. + fn declared(text: &str) -> Result, String> { + let document = text + .parse::() + .map_err(|error| format!("fixture manifest does not decode: {error}"))?; + Ok(dependency_declarations( + &CanonicalPath::spelled("Cargo.toml"), + &document, + )) + } + + /// The one entry every spelling below declares: an ordinary edge written at + /// the key `helpers`, naming the package `threadpak-macroc`, pointing at + /// `macros/macroc`. + fn the_one_entry() -> DeclaredDependency { + DeclaredDependency { + manifest: CanonicalPath::spelled("Cargo.toml"), + kind: EdgeKind::Ordinary, + key: String::from("helpers"), + package: Some(String::from("threadpak-macroc")), + path: Some(String::from("macros/macroc")), + } + } + + /// Nine spellings of ONE declaration, and every one of them resolves + /// identically. + /// + /// Every case here escaped the line reader this replaced, and each was + /// repaired on its own, in its own pass, after its own escape. The dotted + /// key was read as a package name with `.workspace` on the end. The quoted + /// header closed the table instead of opening it, leaving every entry + /// beneath it unread. The literal-string value was half the spellings a path + /// can be written in. The comment after a header made a whole table + /// invisible. The multi-line string and the unicode escape were never + /// reached at all. + /// + /// None of them is handled HERE. There is no case for any of them in this + /// module, which is the point: the decoder owns TOML, so a tenth spelling + /// nobody has thought of is read correctly by a reader that was never told + /// about it. + #[test] + fn every_spelling_of_one_declaration_resolves_identically() -> Result<(), String> { + let spellings: [(&str, &str); 9] = [ + ( + "inline table", + "[dependencies]\nhelpers = { package = \"threadpak-macroc\", path = \"macros/macroc\" }\n", + ), + ( + "sub-table header", + "[dependencies.helpers]\npackage = \"threadpak-macroc\"\npath = \"macros/macroc\"\n", + ), + ( + "dotted keys", + "[dependencies]\nhelpers.package = \"threadpak-macroc\"\nhelpers.path = \"macros/macroc\"\n", + ), + ( + "dotted table key written before any header", + "dependencies.helpers.package = \"threadpak-macroc\"\ndependencies.helpers.path = \"macros/macroc\"\n", + ), + ( + "quoted key path, under both TOML quotes", + "[\"dependencies\".'helpers']\npackage = \"threadpak-macroc\"\npath = \"macros/macroc\"\n", + ), + ( + "literal-string values", + "[dependencies]\nhelpers = { package = 'threadpak-macroc', path = 'macros/macroc' }\n", + ), + ( + "multi-line basic strings", + "[dependencies.helpers]\npackage = \"\"\"threadpak-macroc\"\"\"\npath = \"\"\"\\\nmacros/macroc\"\"\"\n", + ), + ( + "unicode escapes inside values", + "[dependencies]\nhelpers = { package = \"threadpak\\u002Dmacroc\", path = \"macros\\u002Fmacroc\" }\n", + ), + ( + "a comment after the table header", + "[dependencies] # inherited from the workspace\nhelpers = { package = \"threadpak-macroc\", path = \"macros/macroc\" }\n", + ), + ]; + let expected = vec![the_one_entry()]; + for (name, text) in spellings { + let found = declared(text)?; + assert_eq!(found, expected, "{name} did not resolve to the one entry"); + } + Ok(()) + } + + /// The four spellings that are not merely another way of writing the same + /// thing: each one changes what the declaration IS, and the decoder is what + /// says so. + /// + /// An escaped key in a header — `["dev-dependencies"]` — is the + /// dev-dependency table, spelled so that no reader matching the word would + /// know. A four-quote terminator closes a multi-line string one quote late, + /// so the value carries that quote; the line reader took the first `"` it + /// found and read the path as empty. A quoted key carrying `=` is one key, + /// and the line reader cut it in half at the `=` and read a key that does + /// not exist. And a manifest QUOTING a dependency table inside a multi-line + /// string declares no dependency at all — the line reader read the quoted + /// lines as the table they resemble and reported a phantom edge, which is + /// the one ceiling that file wrote down and could not close. + #[test] + fn a_spelling_that_changes_the_declaration_changes_it_exactly() -> Result<(), String> { + let escaped = + declared("[\"dev\\u002Ddependencies\"]\nhelpers = { path = \"macros/macroc\" }\n")?; + assert_eq!(escaped.len(), 1, "{escaped:?}"); + assert!( + escaped + .first() + .is_some_and(|entry| entry.kind == EdgeKind::Development), + "{escaped:?}" + ); + + let four_quote = declared("[dependencies.helpers]\npath = \"\"\"macros/macroc\"\"\"\"\n")?; + assert!( + four_quote + .first() + .is_some_and(|entry| entry.path() == Some("macros/macroc\"")), + "{four_quote:?}" + ); + + let equals = declared("[dependencies]\n\"a=b\" = { path = \"macros/macroc\" }\n")?; + assert!( + equals.first().is_some_and(|entry| entry.key == "a=b"), + "{equals:?}" + ); + + let quoted_table = declared( + "description = \"\"\"\n[dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n\"\"\"\n", + )?; + assert!( + quoted_table.is_empty(), + "a table quoted inside a string was read as a table: {quoted_table:?}" + ); + Ok(()) + } + + /// The positive control for the census reader: the entries a manifest + /// declares are exactly the entries it declares, and a `[workspace]` pool is + /// not one of them. + /// + /// `[workspace.dependencies]` states what a member MAY inherit. Nothing in + /// it is an edge of the declaring package, which is why the pool is read as + /// what it is rather than as a table whose name happens to contain the word. + #[test] + fn a_workspace_pool_declares_no_edge() -> Result<(), String> { + let pool = declared( + "[workspace.dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n", + )?; + assert!(pool.is_empty(), "{pool:?}"); + let asked = declared( + "[dependencies]\nthreadpak-macroc.workspace = true\n\n[workspace.dependencies]\nthreadpak-macroc = { path = \"macros/macroc\" }\n", + )?; + assert_eq!(asked.len(), 1, "{asked:?}"); + assert!( + asked + .first() + .is_some_and(|entry| entry.identity() == "threadpak-macroc"), + "{asked:?}" + ); + Ok(()) + } + + /// A platform-conditional edge is read exactly like the unconditional edge + /// it conditions, under every kind and under either quote on the predicate. + #[test] + fn a_conditioned_edge_is_read_like_the_edge_it_conditions() -> Result<(), String> { + let conditioned = declared( + "[target.'cfg(unix)'.dev-dependencies]\nhelpers = { path = \"macros/macroc\" }\n", + )?; + assert_eq!(conditioned.len(), 1, "{conditioned:?}"); + assert!( + conditioned + .first() + .is_some_and(|entry| entry.kind == EdgeKind::Development + && entry.path() == Some("macros/macroc")), + "{conditioned:?}" + ); + let whole_tree = declared( + "target = { 'cfg(unix)' = { dependencies = { helpers = { path = \"macros/macroc\" } } } }\n", + )?; + assert_eq!(whole_tree.len(), 1, "{whole_tree:?}"); + Ok(()) + } + + /// A key path names a value or it names nothing, and nothing is not an + /// empty string. + #[test] + fn a_key_path_that_names_nothing_is_absent_rather_than_empty() -> Result<(), String> { + let document = "[toolchain]\nchannel = \"1.97.1\"\nlisted = [\"a\", \"b\"]\n" + .parse::() + .map_err(|error| error.to_string())?; + assert_eq!( + string_at(&document, &["toolchain", "channel"]), + Read::Known(String::from("1.97.1")) + ); + assert_eq!( + strings_at(&document, &["toolchain", "listed"]), + Read::Known(vec![String::from("a"), String::from("b")]) + ); + assert!( + string_at(&document, &["toolchain", "nothing"]) + .known() + .is_none() + ); + assert!(string_at(&document, &["toolchain"]).known().is_none()); + Ok(()) + } + + /// The census this repository commits, entry for entry. + /// + /// A reader replacement moves numbers only where the TREE moved, and this is + /// where that is pinned rather than asserted. Nineteen entries across seven + /// committed manifests; the root manifest declares none of them, because + /// what it carries is a workspace POOL. + /// + /// It was fifteen before the decoders were admitted, and the four that + /// arrived are the four this crate now reads through: `toml`, + /// `pulldown-cmark`, `serde`, and `serde_json`, every one of them an entry + /// of `xtask/Cargo.toml`. Nothing else moved — same manifests, same kinds, + /// same keys, same declared packages and paths — so the number moved exactly + /// where the tree did and nowhere else. + #[test] + fn the_committed_census_is_nineteen_entries() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let census: Vec = snapshot + .cargo() + .census() + .0 + .iter() + .map(|entry| { + format!( + "{} [{}] {} package={:?} path={:?}", + entry.manifest(), + entry.kind, + entry.key, + entry.package, + entry.path + ) + }) + .collect(); + assert_eq!(census.len(), 19, "{census:#?}"); + assert!( + census.iter().any(|entry| entry + == "xtask/fixtures/renamed-consumer/Cargo.toml [dependencies] tp \ + package=Some(\"threadpak\") path=Some(\"../../..\")"), + "{census:#?}" + ); + // The four the decoders brought, and no fifth. + assert_eq!( + census + .iter() + .filter(|entry| entry.starts_with("xtask/Cargo.toml")) + .count(), + 6, + "{census:#?}" + ); + assert!( + snapshot.cargo().census().of("Cargo.toml").is_empty(), + "the root manifest's workspace pool was read as an edge" + ); + Ok(()) + } +} diff --git a/xtask/src/repository/manifest.rs b/xtask/src/repository/manifest.rs deleted file mode 100644 index 1dc2a21..0000000 --- a/xtask/src/repository/manifest.rs +++ /dev/null @@ -1,617 +0,0 @@ -//! Reading a Cargo manifest. -//! -//! Cargo admits several spellings of the same declaration, and a reader that -//! knew only one of them would let a renamed, test-only, platform-conditional, -//! or dotted entry through unread. These readers report what a manifest -//! DECLARES and nothing more; whether a declaration is lawful is decided in -//! `crate::checks`. -//! -//! # A TOML key is a path, not a name -//! -//! That single fact is what the dependency reader below is built on, and -//! ignoring it is what let a prohibited edge hide. `serde = "1"` under -//! `[dependencies]`, `serde.version = "1"` under the same header, -//! `[dependencies.serde]` with its fields beneath it, and -//! `dependencies.serde.version = "1"` written before any header are four -//! spellings of ONE declaration; Cargo resolves all four to the same key path -//! and so does this reader. A reader that instead cut a line at its first `=` -//! saw the key `threadpak-macros.workspace` where Cargo saw the package -//! `threadpak-macros`, and a name matching no package matched no law either. -//! -//! # The line is the unit, so a declaration that is not one line is unread -//! -//! Every reader here is line-oriented, which is exact for the manifests this -//! repository commits and for every spelling named above. What a line-oriented -//! reader cannot do is tell structure from data once a value stops closing on -//! the line that opened it. The lines that value spans are read as the headers -//! and keys they resemble, so the reader believes a table ENDED where a string -//! merely continued, and every key after it lands somewhere else. -//! -//! That was not a ceiling. It was a hole, and it was executed. MEASURED on -//! cargo 1.97.1, against the real root manifest, with the gate run as its own -//! compiled binary: a `[dev-dependencies.helpers]` sub-table carrying a -//! multi-line string whose body holds one line shaped like a table header is -//! accepted by cargo, resolves `threadpak → threadpak-macroc`, and passed the -//! topology law — because by the time the reader reached `package` and `path` -//! it no longer believed it was inside a dependency table at all. Three more -//! spellings buy the same forgetting and were measured accepted and resolving -//! the same edge: the multi-line LITERAL string, the bracket list whose -//! elements sit on their own lines with one of them shaped like a header, and -//! the inline table spread across lines, whose continuation lines are read as -//! fresh entries of the enclosing table. A fourth needs no dependency table at -//! all: a spanning value anywhere in the file leaves the reader inside whatever -//! header its body quoted, so a dotted `dev-dependencies.helpers.package` key -//! written afterwards resolves for cargo and reaches no law here. -//! -//! So this reader refuses rather than guesses. [`dependency_declarations`] -//! reports what it did not enter in [`ManifestDependencies::unread`], and the -//! topology law refuses a manifest carrying any of three shapes: -//! -//! **A value that does not close on the line that opens it** — a multi-line -//! basic string, a multi-line literal string, a bracket list broken across -//! lines, or an inline table broken across lines. The refusal is on the SHAPE -//! and never on what the body says, because a rule that read the body to decide -//! whether the body was dangerous would be reading the body. -//! -//! **A basic string carrying a `\` escape**, which is the second half of the -//! same honesty and is described under its own heading below. -//! -//! **A dependency table written as an INLINE table**, whose entries live inside -//! one line's value rather than on lines of their own. -//! -//! Reading any of them properly is the typed repository model's migration, and a -//! second parser seated here would be the duplicate authority this repository is -//! eliminating. -//! -//! # Which way the refusal is allowed to be wrong -//! -//! Both delimiters are handled, `"""` and `'''`, and so are `[` and `{`. A value -//! that closes where it opens is READ, not refused, so `notes = """one line"""` -//! is self-contained and passes, and so does every other single-line use of -//! either delimiter. The closing delimiter of a basic string is still found with -//! `\` escapes honoured the way TOML honours them, and that has not stopped -//! mattering now that an escape is refused on its own account: it is what makes -//! `notes = "a \" b"` a value that CLOSES and carries an escape, rather than one -//! reported as spanning lines it does not span. Two shapes, two repairs, and a -//! reader that confused them would send an author to the wrong one. -//! -//! What is refused bluntly is the spanning shape itself, whatever it holds — a -//! `description` spanning three lines of harmless prose is refused exactly like -//! one quoting a table, and so is a workspace `members` list nobody could hide -//! an edge inside. That costs this repository one real line, `workspace.members` -//! in the root manifest, which is written on one line and says there why. It is -//! the affordable direction: a manifest is REFUSED where it might have been -//! read, and never READ where it should have been refused. The reverse rule — -//! refuse only when the spanned body looks dangerous — would put the reader back -//! inside the value it just admitted it cannot enter. -//! -//! # What a value SAYS is not what it MEANS -//! -//! The second refusal, in a different mechanism, found while measuring the -//! first. It is not a ceiling: it was a hole, MEASURED on cargo 1.97.1 against -//! the real root manifest with the gate run as its own compiled binary. Cargo -//! decodes a basic string's escapes before it resolves anything, and this reader -//! compares the TEXT of a value against the names Cargo compares the DECODED -//! value against, so this manifest was accepted, resolved -//! `threadpak -> threadpak-macroc`, and the topology law printed PASS: -//! -//! ```toml -//! [dev-dependencies] -//! helpers = { package = "threadpak\u002Dmacroc", path = "\u006Dacros/macroc" } -//! ``` -//! -//! `\u002D` is six characters here and one `-` to Cargo. So a basic string -//! carrying a `\` is reported UNREAD, whatever the escape spells: TOML admits -//! `\u`, `\U`, `\n`, `\t`, `\\`, `\"` and the rest, all of them decoded by Cargo -//! and none of them decoded here, and a rule that refused only the escapes -//! somebody had thought of would be the same guess this file exists to stop. -//! Decoding them instead is the typed repository model's job; refusing is what -//! an undecoded reader may honestly do in the meantime. -//! -//! A LITERAL string is a different value and is left READ, which is measured -//! rather than assumed. TOML decodes nothing inside `'…'`, and cargo agrees: -//! `package = 'threadpak\u002Dmacroc'` is refused by CARGO ITSELF with `invalid -//! character \ in package name`, because nothing turned those six characters -//! into a `-`, so there is no edge for an escape to hide there. What a `\` in a -//! literal string IS, is a Windows path separator: `path = 'macros\macroc'` is -//! accepted by cargo, resolves the edge, and is caught here — refusing it would -//! be a false refusal on a lawful spelling, so it is not refused. -//! -//! The distinction is exactly TOML's, and it is why the escape scan runs inside -//! the string skipper rather than over the line: a `\` is an escape where the -//! string it sits in decodes escapes, and a character where it does not. - -/// Every Cargo dependency-edge kind, each of which the topology law covers. -const DEPENDENCY_TABLE_KINDS: [&str; 3] = - ["dependencies", "dev-dependencies", "build-dependencies"]; - -/// The table a platform-conditional dependency table hangs beneath. -const TARGET_TABLE: &str = "target"; - -/// Extracts the double-quoted value of a `key = "value"` line. -pub(crate) fn quoted_value(text: &str, key: &str) -> Result { - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix(key) - && let Some(rest) = rest.trim_start().strip_prefix('=') - { - return Ok(rest.trim().trim_matches('"').to_string()); - } - } - Err(format!("no `{key}` line found")) -} - -/// Extracts the items of a `key = ["a", "b"]` bracket list. -pub(crate) fn bracket_list(text: &str, key: &str) -> Result, String> { - let start = text - .find(&format!("{key} = [")) - .ok_or_else(|| format!("no `{key}` list found"))?; - let rest = text.get(start..).ok_or_else(|| String::from("bad slice"))?; - let open = rest.find('[').ok_or_else(|| String::from("no bracket"))?; - let close = rest - .find(']') - .ok_or_else(|| format!("unterminated `{key}` list"))?; - let inner = rest - .get(open.saturating_add(1)..close) - .ok_or_else(|| String::from("bad slice"))?; - Ok(inner - .split(',') - .map(|item| item.trim().trim_matches('"').to_string()) - .filter(|item| !item.is_empty()) - .collect()) -} - -/// One dependency entry, as `(edge kind, entry key, declared package, declared -/// path)`. -pub(crate) type DependencyEntry = (&'static str, String, Option, Option); - -/// One declaration this reader did not enter, spelled as the key path it sits -/// at. -/// -/// Three shapes rather than one word, because they take three repairs: one is -/// spelled out as a table with its entries on their own lines, one is put back -/// on the single line it belongs on, and one is written with the characters it -/// means. A reader that reported them the same way would tell an author to fix -/// the wrong thing. -pub(crate) enum Unread { - /// A whole dependency table written as an inline table. Its entries sit - /// inside one line's value, and this reader does not enter a value. - InlineTable(String), - /// A value that does not close on the line that opens it. The lines it - /// spans are data, and this reader reads lines — so what those lines look - /// like is not what they are. - MultiLineValue(String), - /// A basic string carrying a `\` escape. Cargo decodes it before resolving - /// anything and this reader does not, so what the value says here is not - /// what it means there. A literal string decodes nothing and is not this. - EscapedValue(String), -} - -/// What one manifest declares about its dependencies: the entries a reader -/// resolved, and the declarations it could not enter. -/// -/// The second field exists because a reader that returned only what it managed -/// to read would answer "no prohibited edge" and "no reading happened" with the -/// same empty list. A caller gets both facts or neither. -pub(crate) struct ManifestDependencies { - /// Every dependency entry the manifest declares, one per entry rather than - /// one per line: a dotted entry spelled across several lines is one entry, - /// which is what Cargo resolves it to. - pub(crate) entries: Vec, - /// Every declaration this reader could not enter, so that what it did not - /// read is reported UNREAD rather than reported absent. - pub(crate) unread: Vec, -} - -/// What a manifest declares about its dependencies. -/// -/// Every line is resolved to the full key path it sits at — the enclosing -/// table header's path, then its own key's — and that path is a dependency -/// declaration when it reads `[target, SPEC,] KIND, NAME, FIELD…`. Ordinary, -/// renamed, dev, build, target-specific, quoted, dotted, and sub-table -/// dependencies therefore arrive by one road rather than by a spelling each, -/// and a spelling nobody thought of is read correctly if Cargo resolves it to -/// that shape. -/// -/// Entries are keyed by `(kind, name)` within one table block, so the several -/// lines of a dotted entry accumulate into the one entry they declare. Blocks -/// do not merge across headers: a package named in a bare table and again under -/// a `target.'…'` prefix is two declarations and stays two entries. -/// -/// A line whose value does not close on it, or whose basic string carries an -/// escape, is reported UNREAD and is never resolved to an entry, wherever in the -/// file it sits. The first check is on every such line rather than only on the -/// ones inside a dependency table, because a value that spans lines takes the -/// reader's idea of which table it is in with it, and the keys that idea then -/// mis-seats are written after the value, not inside it. The second is on every -/// line for a plainer reason: a reader that does not decode has no business -/// deciding which undecoded values were the important ones. -pub(crate) fn dependency_declarations(manifest_text: &str) -> ManifestDependencies { - let mut entries: Vec = Vec::new(); - let mut unread: Vec = Vec::new(); - let mut table: Vec = Vec::new(); - let mut block_start = 0usize; - for raw in manifest_text.lines() { - let line = strip_comment(raw); - if line.is_empty() { - continue; - } - if let Some(header) = line - .strip_prefix('[') - .and_then(|rest| rest.strip_suffix(']')) - { - table = key_path(header); - block_start = entries.len(); - if let Some((kind, name, fields)) = dependency_position(&table) - && fields.is_empty() - { - let _seated = seat(&mut entries, block_start, kind, name); - } - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let mut place = table.clone(); - place.extend(key_path(key)); - let value = value.trim(); - match scan_value(value) { - ScannedValue::Read => {} - ScannedValue::SpansLines => { - unread.push(Unread::MultiLineValue(place.join("."))); - continue; - } - ScannedValue::Escaped => { - unread.push(Unread::EscapedValue(place.join("."))); - continue; - } - } - let Some((kind, name, fields)) = dependency_position(&place) else { - if value.starts_with('{') - && let Some(spelling) = unenterable_table(&place) - { - unread.push(Unread::InlineTable(spelling)); - } - continue; - }; - let index = seat(&mut entries, block_start, kind, name); - let sole = if fields.len() == 1 { - fields.first().map(String::as_str) - } else { - None - }; - let Some((_, _, package, path)) = entries.get_mut(index) else { - continue; - }; - if fields.is_empty() { - *package = quoted_assignment(value, "package"); - *path = quoted_assignment(value, "path"); - } else if sole == Some("package") { - *package = quoted_text(value); - } else if sole == Some("path") { - *path = quoted_text(value); - } - } - ManifestDependencies { entries, unread } -} - -/// Where in `entries` the `(kind, name)` entry of the current table block sits, -/// seating a fresh one when the block has not named it yet. -/// -/// The search starts at the block's first entry, so seating is scoped to the -/// block: the dotted lines of one entry find each other, and the same package -/// named under a second header seats a second entry. -fn seat( - entries: &mut Vec, - block_start: usize, - kind: &'static str, - name: &str, -) -> usize { - let existing = entries - .iter() - .enumerate() - .skip(block_start) - .find(|(_, (entry_kind, entry_key, _, _))| { - *entry_kind == kind && entry_key.as_str() == name - }) - .map(|(index, _)| index); - if let Some(index) = existing { - return index; - } - let index = entries.len(); - entries.push((kind, name.to_string(), None, None)); - index -} - -/// The dependency declaration one key path names: its edge kind, the entry it -/// names, and the fields addressed beneath that entry. -/// -/// A path that names a dependency TABLE without naming an entry in it — the -/// `[dependencies]` header itself — is not a declaration and returns nothing; -/// the lines beneath it arrive here with their own key appended. -fn dependency_position(place: &[String]) -> Option<(&'static str, &str, &[String])> { - let rest = after_target(place); - let first = rest.first()?; - let kind = DEPENDENCY_TABLE_KINDS - .into_iter() - .find(|kind| *kind == first.as_str())?; - let name = rest.get(1)?; - if name.is_empty() { - return None; - } - Some((kind, name.as_str(), rest.get(2..).unwrap_or_default())) -} - -/// The key path with a `target.'…'` prefix removed, so a platform-conditional -/// declaration is read exactly like the unconditional one it conditions. -fn after_target(place: &[String]) -> &[String] { - if place - .first() - .is_some_and(|first| first.as_str() == TARGET_TABLE) - { - return place.get(2..).unwrap_or_default(); - } - place -} - -/// The key path this reader cannot enter, where an inline value sits at one: -/// a whole dependency table written as `KIND = { … }`, or a whole `target` -/// tree written the same way. -/// -/// The entries would be inside the value, and this reader reads lines. Naming -/// the path is what lets the topology law refuse the manifest instead of -/// reporting an absence it never established. -fn unenterable_table(place: &[String]) -> Option { - let rest = after_target(place); - let names_target = place - .first() - .is_some_and(|first| first.as_str() == TARGET_TABLE); - if rest.is_empty() && names_target { - return Some(place.join(".")); - } - if rest.len() == 1 - && rest - .first() - .is_some_and(|first| DEPENDENCY_TABLE_KINDS.contains(&first.as_str())) - { - return Some(place.join(".")); - } - None -} - -/// The segments of one TOML key path, quotes removed and unquoted whitespace -/// dropped. -/// -/// A dot separates segments only outside a quoted segment, so a target -/// predicate keeps its own dots and its own inner quotes. What is out of reach -/// by construction is an escape sequence inside a basic string: a key needing -/// one cannot name a Cargo package, whose characters are alphanumerics, `-`, -/// and `_`. -fn key_path(key: &str) -> Vec { - let mut segments = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - for character in key.chars() { - if let Some(open) = quote { - if character == open { - quote = None; - } else { - current.push(character); - } - } else if character == '"' || character == '\'' { - quote = Some(character); - } else if character == '.' { - segments.push(std::mem::take(&mut current)); - } else if !character.is_whitespace() { - current.push(character); - } - } - segments.push(current); - segments -} - -/// One line with its comment removed and its ends trimmed. -/// -/// A `#` opens a comment only outside a string, so a path or a predicate -/// carrying one survives. Removing it here is what keeps a header with a -/// comment after it a header, and what stops the word `path` inside a comment -/// from being read as a declaration. -fn strip_comment(line: &str) -> &str { - let mut quote: Option = None; - for (index, character) in line.char_indices() { - match quote { - Some(open) if character == open => quote = None, - Some(_) => {} - None => { - if character == '"' || character == '\'' { - quote = Some(character); - } else if character == '#' { - return line.get(..index).unwrap_or_default().trim(); - } - } - } - } - line.trim() -} - -/// The quoted value assigned to `key` anywhere in one line of manifest text, -/// whether the line is a table entry or an inline table body. The key is -/// matched whole, so `package` never matches inside a longer key. -fn quoted_assignment(text: &str, key: &str) -> Option { - let mut from = 0usize; - loop { - let rest = text.get(from..)?; - let offset = rest.find(key)?; - let start = from.saturating_add(offset); - let end = start.saturating_add(key.len()); - let before_is_key = text - .get(..start) - .and_then(|head| head.chars().next_back()) - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); - if !before_is_key - && let Some(tail) = text.get(end..) - && let Some(value) = tail.trim_start().strip_prefix('=') - && let Some(quoted) = quoted_text(value) - { - return Some(quoted); - } - from = end; - } -} - -/// What one line's value is, as far as a reader that never decodes it can tell. -enum ScannedValue { - /// The value closes on its line and passed through nothing this reader - /// cannot resolve: it is the value it appears to be. - Read, - /// The value does not close on the line that opens it, so the next line is - /// inside it rather than after it. - SpansLines, - /// The value closes, and a basic string inside it carries a `\` escape, so - /// the characters here are not the characters Cargo resolves against. - Escaped, -} - -/// What a string this reader stepped over carried that it cannot resolve. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Carried { - /// Nothing. Every character in the body means itself, here and to Cargo. - Nothing, - /// A `\` escape, which Cargo decodes before it resolves anything and this - /// reader does not decode at all. - Escape, -} - -/// One string stepped over whole: what follows it, and what it carried. -struct SkippedString<'a> { - /// The text after the closing delimiter. - rest: &'a str, - /// What the body carried. Never [`Carried::Escape`] for a literal string, - /// which has no escapes to carry. - carried: Carried, -} - -/// Both questions this reader has to answer about a value before it trusts -/// anything it reads off the line, or off the next one. -/// -/// A value that closes here means the next line is structure, and a value that -/// does not means the next line is somebody's data wearing structure's clothes. -/// Strings are skipped whole under either quote and either width, so a bracket -/// or a brace inside one counts for nothing, and brackets and braces outside one -/// are counted in and out. What is left open at the end of the line — a string -/// still running, or a list or an inline table still nested — spans lines. What -/// was passed on the way is reported too: a `\` inside a BASIC string is an -/// escape Cargo decodes and this reader does not, and a `\` inside a literal -/// string is a character in both, which is why the answer comes back from the -/// string skipper rather than from a scan of the line. -/// -/// This answers where a value ENDS and what it PASSED THROUGH, never what it -/// means. It resolves no key, turns no escape into the character it stands for, -/// and produces no value; a `#` outside a string ends the line here for the same -/// reason it does in [`strip_comment`], and nothing else about the body is -/// looked at. -fn scan_value(value: &str) -> ScannedValue { - let mut depth = 0usize; - let mut carried = Carried::Nothing; - let mut rest = value; - loop { - let Some(character) = rest.chars().next() else { - return settled(depth, carried); - }; - if character == '#' { - return settled(depth, carried); - } - if character == '"' || character == '\'' { - let Some(skipped) = string_end(rest, character) else { - return ScannedValue::SpansLines; - }; - if skipped.carried == Carried::Escape { - carried = Carried::Escape; - } - rest = skipped.rest; - continue; - } - if character == '[' || character == '{' { - depth = depth.saturating_add(1); - } else if character == ']' || character == '}' { - depth = depth.saturating_sub(1); - } - let Some(after) = rest.get(character.len_utf8()..) else { - return settled(depth, carried); - }; - rest = after; - } -} - -/// The verdict once the line runs out. -/// -/// Still nested is a value that spans lines; an escape passed on the way is a -/// value whose characters are not the ones Cargo resolves against. Nesting is -/// answered first because a value that has not ended yet has not finished saying -/// what it carries either. -fn settled(depth: usize, carried: Carried) -> ScannedValue { - if depth != 0 { - return ScannedValue::SpansLines; - } - match carried { - Carried::Nothing => ScannedValue::Read, - Carried::Escape => ScannedValue::Escaped, - } -} -/// The string a value opens with, stepped over whole, or nothing when it does -/// not close on the same line. -/// -/// The delimiter is whichever the value opened with — one quote or three, basic -/// or literal — and the closing delimiter is the same one, which is what makes -/// `"""` a different string from `"` rather than three of them in a row. -/// -/// Whether the string DECODES is the difference TOML draws between its two -/// kinds, and it decides both answers here. In a basic string a `\` escapes the -/// character after it, so `"a \" b"` closes at its last quote and not its -/// second, and the escape is reported because Cargo will turn it into a -/// character this reader never will. In a literal string there are no escapes at -/// all: a `\` is a backslash to TOML, to Cargo, and here, so it is stepped over -/// like any other character and reported as nothing. That is measured rather -/// than assumed — `package = 'threadpak\u002Dmacroc'` is refused by cargo itself -/// as an invalid package name, because nothing decoded it. -fn string_end(text: &str, quote: char) -> Option> { - let triple = if quote == '"' { "\"\"\"" } else { "'''" }; - let decodes = quote == '"'; - let (delimiter, mut rest) = if let Some(body) = text.strip_prefix(triple) { - (triple, body) - } else { - (text.get(..quote.len_utf8())?, text.get(quote.len_utf8()..)?) - }; - let mut carried = Carried::Nothing; - loop { - if let Some(after) = rest.strip_prefix(delimiter) { - return Some(SkippedString { - rest: after, - carried, - }); - } - let character = rest.chars().next()?; - let mut step = character.len_utf8(); - if decodes && character == '\\' { - carried = Carried::Escape; - if let Some(after) = rest.get(step..).and_then(|tail| tail.chars().next()) { - step = step.saturating_add(after.len_utf8()); - } - } - rest = rest.get(step..)?; - } -} - -/// The contents of the quoted string one value opens with, under either TOML -/// quote. A literal string is a spelling of the same value a basic string -/// carries, so a path or a rename written in single quotes is read. -fn quoted_text(value: &str) -> Option { - let mut characters = value.trim_start().chars(); - let open = characters.next()?; - if open != '"' && open != '\'' { - return None; - } - let rest = characters.as_str(); - let end = rest.find(open)?; - rest.get(..end).map(str::to_string) -} diff --git a/xtask/src/repository/markdown.rs b/xtask/src/repository/markdown.rs new file mode 100644 index 0000000..738ec2c --- /dev/null +++ b/xtask/src/repository/markdown.rs @@ -0,0 +1,1225 @@ +//! Markdown structure, and the data blocks this repository writes inside it. +//! +//! Two authorities, and the line between them is the point of this module. +//! +//! **What a document's STRUCTURE is** — where a fenced block begins, where it +//! ends, what its info string says, and what is prose rather than data — is a +//! question about Markdown, and `pulldown-cmark` answers it. Nothing here counts +//! fences, tracks indentation to find a block, or matches a line against a +//! backtick. +//! +//! **What one data block DECLARES** is this repository's own schema, and this +//! module owns it, because the blocks are yaml-SHAPED and are not YAML +//! documents. Measured: `macros/macroc/README.md` and `testpak/README.md` write +//! their tooling ledger as a scalar mapping value followed by more-indented +//! keys, which is a YAML error rather than a YAML document, so a YAML decoder +//! handed those blocks refuses them. And no YAML decoder can be admitted here +//! anyway: `deny.toml` sets `multiple-versions = "deny"`, and measured against +//! the committed lock, `yaml-rust2` resolves `hashbrown` 0.16 beside the +//! `hashbrown` 0.17 this graph already holds, while `saphyr` and +//! `saphyr-parser` reach `thiserror`, which requires `syn` 2 beside the `syn` 3 +//! this workspace pins. Admitting either would break a supply-chain law to +//! satisfy a reading law. +//! +//! So the schema is read HERE, and the reading is narrowed twice so that what it +//! is narrowed to is small enough to be total: +//! +//! 1. **A row exists only inside a block the parser found**, and only inside one +//! whose declared SCHEMA the reading is about. A `green:` written in prose, in +//! a worked example, in a `text` fence, or in a block declaring a different +//! schema reaches nothing. The whole-file scan that could not tell those apart +//! is gone. +//! 2. **A record is delimited by the sequence item that opens it**, never by how +//! deep its fields are indented. The indentation grammar is gone with it: no +//! reader here compares one line's leading whitespace to another's. +//! +//! # The ceiling, and which way it falls +//! +//! What this establishes is that a block declares the schema's own shape — a +//! document key, a sequence item, a field, a continuation. A block written in +//! YAML's flow style, carrying an anchor, an alias, a block scalar, or a quoted +//! key, is read as the lines it is written on rather than as the YAML it would +//! decode to. That direction fails CLOSED: such a block's fields are not +//! recognized, so its records carry no rows, and a record carrying no rows is +//! refused by the join rather than qualifying quietly. What it costs is an +//! author who wanted an exotic spelling, who is told exactly which line was not +//! read. +//! +//! It opens when a YAML mechanism can be admitted without breaking +//! `multiple-versions = "deny"` — at which point this schema reading is deleted +//! rather than taught, because a decoder and a reader agreeing about a document +//! is two authorities over one fact. + +use std::collections::BTreeMap; +use std::path::Path; + +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; + +use crate::repository::snapshot::CanonicalFileMap; +use crate::repository::types::{ + AbsenceReason, CanonicalPath, GreenRow, ObligationRecord, Read, ReadFailure, +}; + +/// The info string a data block declares itself under. +const DATA_LANGUAGE: &str = "yaml"; + +/// Every Markdown document in the tree, parsed once. +pub(crate) struct MarkdownSnapshot { + /// Keyed by canonical path, so no reader spells a document twice. + documents: BTreeMap>, +} + +impl MarkdownSnapshot { + /// Parses every `.md` file the file map carries. + pub(crate) fn read(files: &CanonicalFileMap) -> Self { + let mut documents = BTreeMap::new(); + for (path, fact) in files.iter() { + if !path.extension_is("md") { + continue; + } + let parsed = match *fact.text() { + Read::Known(ref text) => Read::Known(MarkdownDocument::parse(text)), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + }; + documents.insert(path.clone(), parsed); + } + Self { documents } + } + + /// One parsed document, or the declared absence of the file carrying it. + pub(crate) fn document(&self, path: &CanonicalPath) -> Read<&MarkdownDocument> { + match self.documents.get(path) { + Some(Read::Known(document)) => Read::Known(document), + Some(Read::DeclaredAbsent(reason)) => Read::DeclaredAbsent(*reason), + Some(Read::Unreadable(failure)) => Read::Unreadable(failure.clone()), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchPath), + } + } +} + +/// One Markdown document, reduced to the data blocks it declares. +/// +/// Prose is not carried. What a law asks of a document is which data it +/// declares, and a reading that also carried the prose would be a reading two +/// laws could disagree about. +pub(crate) struct MarkdownDocument { + /// Every fenced block, in document order, each carrying its declared + /// schema. + blocks: Vec, +} + +impl MarkdownDocument { + /// Every fenced block one document declares, in document order. + /// + /// The parser decides what a block IS. This walks its events and keeps the + /// fenced ones, which is the whole of the structure this module reads. + pub(crate) fn parse(text: &str) -> Self { + let mut blocks = Vec::new(); + let mut open: Option = None; + let mut body = String::new(); + for event in Parser::new(text) { + match event { + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => { + open = Some(info.into_string()); + body.clear(); + } + Event::Text(written) if open.is_some() => body.push_str(&written), + Event::End(TagEnd::CodeBlock) => close(&mut open, &mut body, &mut blocks), + Event::Start(_) + | Event::End(_) + | Event::Text(_) + | Event::Code(_) + | Event::InlineMath(_) + | Event::DisplayMath(_) + | Event::Html(_) + | Event::InlineHtml(_) + | Event::FootnoteReference(_) + | Event::SoftBreak + | Event::HardBreak + | Event::Rule + | Event::TaskListMarker(_) => (), + } + } + Self { blocks } + } + + /// The one block declaring a named schema, or the declared absence of one. + /// + /// A document declaring the schema TWICE is a failure rather than a choice: + /// two blocks answering one reading is the duplicate authority this whole + /// model exists to remove, and picking one of them by position is exactly the + /// first-fence rule this replaced. One block declaring TWO schemas is refused + /// on the same rule from the other side — see [`read_block`]. + pub(crate) fn block(&self, schema: BlockSchema) -> Read<&DataBlock> { + let mut declaring: Vec<&DataBlock> = Vec::new(); + for block in &self.blocks { + match block.reading { + BlockReading::ManySchemas(ref many) if many.contains(&schema) => { + return Read::Unreadable(ReadFailure::new( + schema.spelling(), + &declares_many(many), + )); + } + BlockReading::One(one) if one == schema => declaring.push(block), + BlockReading::One(_) + | BlockReading::ManySchemas(_) + | BlockReading::NoSchema + | BlockReading::NotData => (), + } + } + if declaring.len() > 1 { + return Read::Unreadable(ReadFailure::new( + schema.spelling(), + "two data blocks in one document declare this schema, so which one a reading is \ + about is decided by position rather than by the document", + )); + } + match declaring.first().copied() { + Some(block) => Read::Known(block), + None => Read::DeclaredAbsent(AbsenceReason::NoBlockDeclaresThisSchema), + } + } + + /// Every data-language block no reading can be about, one offence each. + /// + /// Reported rather than skipped, and BOTH ways a block can be unjoinable are + /// here. A block declaring no schema this repository reads is a block no + /// reading is about; a block declaring two is a block that answers to + /// whichever reader asks first. A ledger that silently stopped being read is + /// the exact failure this model was built to end, and it does not matter + /// which of the two ways it stopped. + pub(crate) fn unjoinable_data_blocks(&self) -> Vec { + let mut offences = Vec::new(); + for block in &self.blocks { + match block.reading { + BlockReading::NoSchema => offences.push(String::from( + "a fenced data block declares no schema this repository reads, so whatever it \ + carries is joined by nothing and counted by nothing", + )), + BlockReading::ManySchemas(ref many) => offences.push(format!( + "a block declares exactly one schema: {}", + declares_many(many) + )), + BlockReading::One(_) | BlockReading::NotData => (), + } + } + offences + } +} + +/// Closes the block the parser just ended, carrying what its own keys say it is. +fn close(open: &mut Option, body: &mut String, into: &mut Vec) { + let Some(language) = open.take() else { + return; + }; + let carried = std::mem::take(body); + into.push(DataBlock { + reading: read_block(&language, &carried), + body: carried, + }); +} + +/// One schema this repository reads. +/// +/// Identity is taken from the keys a block writes at its own document level, +/// never from where the block sits among the fences. That is the whole repair: +/// the reading this replaced took the FIRST fenced block carrying the data +/// language and called it the one it wanted, which was a fact about the current +/// order of one file rather than about the block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BlockSchema { + /// `phase`, `toolchain`, `workspace_members`: what the repository builds on. + PhaseDeclaration, + /// `home` and `obligations`: one home's obligation ledger. + ObligationLedger, + /// `tooling-obligation`: a tooling home's qualification ledger. + ToolingObligationLedger, + /// `seat` and `state`: a reserved architectural coordinate. + SeatReservation, +} + +/// Every schema this repository reads, and the whole of that population. +/// +/// The array is what makes "exactly one" askable at all: a reading that decides +/// a block's schema by asking questions in an order can only ever answer with +/// the first question that says yes. +const EVERY_SCHEMA: [BlockSchema; 4] = [ + BlockSchema::PhaseDeclaration, + BlockSchema::ObligationLedger, + BlockSchema::ToolingObligationLedger, + BlockSchema::SeatReservation, +]; + +impl BlockSchema { + /// The document-level keys a block must write to declare this schema. + const fn identifying_keys(self) -> &'static [&'static str] { + match self { + BlockSchema::PhaseDeclaration => &["phase"], + BlockSchema::ObligationLedger => &["obligations"], + BlockSchema::ToolingObligationLedger => &["tooling-obligation"], + BlockSchema::SeatReservation => &["seat", "state"], + } + } + + /// How the schema is named in a refusal. + const fn spelling(self) -> &'static str { + match self { + BlockSchema::PhaseDeclaration => "the phase declaration block", + BlockSchema::ObligationLedger => "the obligation ledger block", + BlockSchema::ToolingObligationLedger => "the tooling obligation ledger block", + BlockSchema::SeatReservation => "the seat reservation block", + } + } +} + +/// What one fenced block's own keys say it is. +/// +/// Four states, and the middle two are the reason this is not a schema. "Which +/// schema" and "whether there is exactly one" are different questions, and +/// mixing them into one enum is what let the answer to the second be a matter of +/// declaration order. +enum BlockReading { + /// The block declares exactly one schema. + One(BlockSchema), + /// The block is written in the data language and declares no schema this + /// repository reads. + NoSchema, + /// The block declares more than one, and every one it declared is carried, + /// because a refusal that cannot name them tells nobody what to delete. + ManySchemas(Vec), + /// A fenced block that is not written in the data language at all — a + /// diagram, a shell transcript, a Rust example. + NotData, +} + +/// What one block's keys declare it to be. +/// +/// EVERY schema is calculated and exactly one is required. The reading this +/// replaced asked its four questions in order and returned the first that said +/// yes, so a block writing both `obligations:` and `phase:` was an obligation +/// ledger — not because the document said so, but because that arm was written +/// first. A reader further down the file asking for the phase declaration then +/// found nothing, and the block it was looking at answered to somebody else. Two +/// matches is an offence rather than a race won by declaration order, and moving +/// the arms around can no longer change what a document means. +fn read_block(language: &str, body: &str) -> BlockReading { + if language != DATA_LANGUAGE { + return BlockReading::NotData; + } + let keys = document_keys(body); + let declared: Vec = EVERY_SCHEMA + .into_iter() + .filter(|schema| { + schema + .identifying_keys() + .iter() + .all(|key| keys.iter().any(|written| written == key)) + }) + .collect(); + if declared.len() > 1 { + return BlockReading::ManySchemas(declared); + } + match declared.first() { + Some(&one) => BlockReading::One(one), + None => BlockReading::NoSchema, + } +} + +/// What a block declaring more than one schema is refused with, naming every +/// schema it declared. +/// +/// The schemas are NAMED rather than counted, because a refusal that cannot say +/// which ones tells nobody which key to delete. They are named in the order +/// [`EVERY_SCHEMA`] declares them, so the refusal reads the same on every run. +fn declares_many(schemas: &[BlockSchema]) -> String { + format!( + "one data block declares {} schemas at once ({}), so which reading it is about would be \ + decided by the order a reader asks in rather than by the document", + schemas.len(), + schemas + .iter() + .map(|schema| schema.spelling()) + .collect::>() + .join(", ") + ) +} + +/// The keys one block writes at its own document level, in the order written. +/// +/// A document key is a field written flush against the block's own left edge. +/// That is not an indentation grammar: nothing is compared to anything, and no +/// depth decides what a line BELONGS to — the question here is only which keys +/// the document itself states, which is what a schema identity is made of. +fn document_keys(body: &str) -> Vec { + body.lines() + .filter(|line| !line.starts_with(char::is_whitespace)) + .filter_map(|line| field_key(line).map(str::to_owned)) + .collect() +} + +/// One data block: what its own keys declare it to be, and the text it carries. +pub(crate) struct DataBlock { + /// What the block declares itself to be. + reading: BlockReading, + /// The block's own text, as the parser handed it back. + body: String, +} + +/// One line of a data block, classified by the schema grammar this repository +/// writes. +/// +/// Total over its input: every line is one of these four, so there is no line a +/// reading walks past without having said what it is. +#[derive(Debug, PartialEq, Eq)] +enum BlockLine<'block> { + /// A line stating nothing. + Blank, + /// A sequence ITEM, which is what opens a record: `- key: value`, or `- + /// value` with no key. + Item { + /// The field the item opens with, where it opens with one. + key: Option<&'block str>, + /// The value the item states. + value: &'block str, + }, + /// A field: `key: value`, or `key:` opening a list. + Field { + /// The field's name. + key: &'block str, + /// The value it states, empty where the field opens a list. + value: &'block str, + }, + /// Anything else: the wrapped remainder of the value above it. + Continuation, +} + +/// How one line of a data block reads. +fn classify_line(line: &str) -> BlockLine<'_> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return BlockLine::Blank; + } + if let Some(item) = trimmed.strip_prefix("- ") { + return match field_of(item) { + Some((key, value)) => BlockLine::Item { + key: Some(key), + value, + }, + None => BlockLine::Item { + key: None, + value: item.trim(), + }, + }; + } + match field_of(trimmed) { + Some((key, value)) => BlockLine::Field { key, value }, + None => BlockLine::Continuation, + } +} + +/// The `key: value` one line states, where it states one. +/// +/// A key is an identifier written before a colon that is followed by a space or +/// by the end of the line. Prose wrapping onto a following line does not open a +/// field, which is what keeps a wrapped account from being read as a row. +fn field_of(line: &str) -> Option<(&str, &str)> { + let key = field_key(line)?; + let rest = line.get(key.len().saturating_add(1)..)?; + Some((key, rest.trim())) +} + +/// The field key one line opens with, where it opens with one. +/// +/// A key is an IDENTIFIER written before a colon, and what follows the colon is +/// the value however it is spaced — `green: x`, `green:x`, `green:` with nothing +/// after it, and `green:` followed by a tab are one field written four ways. The +/// reader this replaced matched a literal `"green: "` in one place and a bare +/// `"green:"` in another, and the row a keystroke of whitespace dropped from the +/// strict side was seated by the loose one and claimed by neither. +/// +/// The identifier restriction is what keeps prose from opening a field: a +/// wrapped account carrying `e.g.:` or a URL's `https://` states no key, because +/// neither head is written in a key's characters. +fn field_key(line: &str) -> Option<&str> { + let (key, _) = line.split_once(':')?; + if key.is_empty() || !key.chars().all(is_key_character) { + return None; + } + Some(key) +} + +/// The characters a schema key is written with. +fn is_key_character(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' || character == '-' +} + +/// What the phase declaration block states. +pub(crate) struct PhaseDeclaration { + /// The toolchain the repository declares it builds on. + toolchain: String, + /// The workspace members the repository declares. + members: Vec, +} + +impl PhaseDeclaration { + /// The toolchain the block declares. + pub(crate) fn toolchain(&self) -> &str { + &self.toolchain + } + + /// The workspace members the block declares, in the order written. + pub(crate) fn members(&self) -> &[String] { + &self.members + } +} + +/// The phase declaration one document states. +pub(crate) fn phase_declaration(document: &MarkdownDocument) -> Read { + let block = match document.block(BlockSchema::PhaseDeclaration) { + Read::Known(block) => block, + Read::DeclaredAbsent(reason) => return Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => return Read::Unreadable(failure), + }; + let mut toolchain: Option = None; + let mut members: Vec = Vec::new(); + let mut listing = false; + for line in block.body.lines() { + match classify_line(line) { + BlockLine::Field { key, value } => { + listing = value.is_empty() && key == "workspace_members"; + if key == "toolchain" { + toolchain = Some(unquoted(value).to_owned()); + } + } + BlockLine::Item { key: None, value } => { + if listing { + members.push(unquoted(value).to_owned()); + } + } + BlockLine::Item { key: Some(_), .. } | BlockLine::Blank | BlockLine::Continuation => (), + } + } + match toolchain { + Some(toolchain) => Read::Known(PhaseDeclaration { toolchain, members }), + None => Read::Unreadable(ReadFailure::new( + "the phase declaration block", + "states no `toolchain:` field", + )), + } +} + +/// One scalar with its surrounding quotes removed, under either quote. +fn unquoted(value: &str) -> &str { + for quote in ['"', '\''] { + if let Some(inner) = value + .strip_prefix(quote) + .and_then(|rest| rest.strip_suffix(quote)) + { + return inner; + } + } + value +} + +/// The obligation records one home declares, and what the reading refused. +pub(crate) struct ObligationLedger { + /// Every record the block declared, in the order written. + pub(crate) records: Vec, + /// What the reading itself refused: a row no record owns, an item opening + /// with no identity. + pub(crate) offences: Vec, +} + +impl ObligationLedger { + /// Every record the block declared. + pub(crate) fn records(&self) -> &[ObligationRecord] { + &self.records + } + + /// What the reading refused. + pub(crate) fn offences(&self) -> &[String] { + &self.offences + } +} + +/// The field a record opens with. +const RECORD_IDENTITY: &str = "id"; + +/// The field a record states its positive control in. +const GREEN_FIELD: &str = "green"; + +/// The field a record states its reversal in. +const RED_FIELD: &str = "red"; + +/// The field a tooling obligation states its reversal in. +const TOOLING_RED_FIELD: &str = "tooling-red"; + +/// The obligation ledger one document declares. +/// +/// A record opens at the sequence ITEM that states its identity, and every +/// field written after that item and before the next one belongs to it. Nothing +/// here looks at how deep a line is written: the item marker is the delimiter, +/// which is what a sequence item IS. +pub(crate) fn obligation_ledger(document: &MarkdownDocument, home: &str) -> Read { + let block = match document.block(BlockSchema::ObligationLedger) { + Read::Known(block) => block, + Read::DeclaredAbsent(reason) => return Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => return Read::Unreadable(failure), + }; + let mut records: Vec = Vec::new(); + let mut offences: Vec = Vec::new(); + for line in block.body.lines() { + match classify_line(line) { + BlockLine::Item { key, value } => match key { + Some(RECORD_IDENTITY) => records.push(ObligationRecord { + id: value.to_owned(), + green: Vec::new(), + red: Vec::new(), + }), + Some(other) => offences.push(format!( + "{home}: an obligation record opens with `{other}:` rather than with \ + `{RECORD_IDENTITY}:`, so the rows written beneath it belong to no obligation \ + anything can name" + )), + None => offences.push(format!( + "{home}: an obligation record opens with `{value}`, which states no field at \ + all, so the rows written beneath it belong to no obligation anything can name" + )), + }, + BlockLine::Field { key, value } => { + let row = match key { + GREEN_FIELD | RED_FIELD => key, + _ => continue, + }; + let Some(record) = records.last_mut() else { + offences.push(format!( + "{home}: a `{row}:` row stands outside every obligation record. This join \ + reads rows through the record that declared them, so a row no record owns \ + is joined by nothing and counted by nothing — the repair is to write it \ + inside the record it belongs to, beneath that record's own `- id:` item" + )); + continue; + }; + if row == GREEN_FIELD { + record.green.push(classify_green_row(value)); + } else { + record.red.push(value.to_owned()); + } + } + BlockLine::Blank | BlockLine::Continuation => (), + } + } + Read::Known(ObligationLedger { records, offences }) +} + +/// Every `tooling-red:` row one tooling document declares, in the order +/// written. +/// +/// Read exactly like a core `red:` row — same field grammar, same emptied row +/// still read, same name-then-prose value — and counted on its own denominator. +/// +/// Several rows state what their reversal does after naming it, and one names +/// sibling fixtures in that prose. The ledger resolves the first token and reads +/// none of the rest, which is a stated ceiling rather than a convention: a row +/// carrying more than one fixture reference has exactly one of them joined. +/// Closing that takes typed fixture references in the row itself. +pub(crate) fn tooling_reversal_rows(document: &MarkdownDocument) -> Read> { + let block = match document.block(BlockSchema::ToolingObligationLedger) { + Read::Known(block) => block, + Read::DeclaredAbsent(reason) => return Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => return Read::Unreadable(failure), + }; + let mut rows = Vec::new(); + for line in block.body.lines() { + if let BlockLine::Field { key, value } = classify_line(line) + && key == TOOLING_RED_FIELD + { + rows.push(value.to_owned()); + } + } + Read::Known(rows) +} + +/// The spelling a green row opens with when its positive control is a +/// compile-time seat. +const COMPILE_TIME_SEAT: &str = "laws.rs"; + +/// The separator a `none` or `owed` disposition states its account after, as +/// every such row in this repository is written. +const DISPOSITION_DASH: char = '—'; + +/// The opener a `structural` disposition states its account inside. +const DISPOSITION_PAREN: char = '('; + +/// The grammar one green row is written in, chosen by the word the row opens +/// with. +/// +/// Three grammars, and being none of them is the absence of one. This exists so +/// that the ceiling on an account — what a row may state, and where it must stop +/// — is asked of the ROW's grammar in one place, instead of being remembered +/// inside whichever branch happens to build the row. Three branches each +/// carrying their own ceiling is three chances to forget one, and the history of +/// this reading is exactly that: the rule was written for the seat branch, the +/// route branch went on taking its first token and discarding the rest, and the +/// defect regrew one branch to the right. A rule applied per site regrows one +/// site over; a rule applied to the class does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Grammar { + /// `laws.rs module::law`: the account is the target, and a target is one + /// token. + Seat, + /// `path/to/file.rs`: the opening word IS the claim, so the account is + /// silent — nothing at all follows the path. + Route, + /// `none — …`, `owed — …`, `structural (…)`: the account is a SENTENCE, + /// opened by this character, and it runs for as long as the sentence takes. + Disposition(char), +} + +/// One green row's value, classified. Nothing is dropped: a value no lawful +/// spelling reads comes back as [`GreenRow::Unreadable`] carrying itself. +/// +/// The row's grammar is decided first, its account is held to that grammar's +/// ceiling second, and only then is a row built. The ceiling is applied HERE, to +/// every grammar at once, rather than inside the arms below: an arm can only +/// interpret an account this function has already agreed is the whole of what +/// the row stated, so there is no arm left that could read past its own account. +pub(crate) fn classify_green_row(value: &str) -> GreenRow { + let value = value.trim(); + let Some(opening) = value.split_whitespace().next() else { + return GreenRow::Unreadable(value.to_owned()); + }; + let account = value.get(opening.len()..).map_or("", str::trim); + let read = green_grammar(opening) + .filter(|&grammar| states_only_its_account(grammar, account)) + .and_then(|grammar| match grammar { + Grammar::Seat => seat_target(account).map(|(module, law)| GreenRow::CompileTimeSeat { + module: module.to_owned(), + law: law.to_owned(), + }), + Grammar::Route => Some(GreenRow::Route(opening.to_owned())), + Grammar::Disposition(opener) => { + accounts_after(account, opener).then_some(GreenRow::Disposition) + } + }); + match read { + Some(row) => row, + None => GreenRow::Unreadable(value.to_owned()), + } +} + +/// Which grammar a green row's opening word puts it in, or none where the word +/// opens no grammar this repository reads. +/// +/// The seat is asked about BEFORE the route, and the order is load-bearing: +/// `laws.rs` is itself a path to a Rust file, so a reader that asked the route +/// question first would read every one of this repository's seat rows as a route +/// naming a file at the repository root and demand a test binary of it. The +/// opening word alone decides the grammar; nothing after it is looked at here, +/// because what a row may say after its opening word is the next question and +/// has one answer for the whole class. +fn green_grammar(opening: &str) -> Option { + if opening == COMPILE_TIME_SEAT { + Some(Grammar::Seat) + } else if opening == "none" || opening == "owed" { + Some(Grammar::Disposition(DISPOSITION_DASH)) + } else if opening == "structural" { + Some(Grammar::Disposition(DISPOSITION_PAREN)) + } else if is_rust_route(opening) { + Some(Grammar::Route) + } else { + None + } +} + +/// Whether a green row states the account its grammar defines and NOTHING after +/// it. +/// +/// THE ceiling on a green account, and the only one. Whatever kind of green row +/// it is, the account is exactly the tokens that row's grammar defines, and a +/// token past them makes the row [`GreenRow::Unreadable`]. One statement, over +/// the class — because it has been stated per branch, and per branch it only +/// ever held for the branch written last. +/// +/// # The disposition grammar has no ceiling, and that is a decision +/// +/// A `none`, `owed`, or `structural` row accounts for why NO file holds a +/// positive control, and an account of that kind is prose. Prose legitimately +/// has many tokens, so there is no number to hold it to and this rule admits it +/// without one — deliberately, and stated here rather than left as the case +/// nobody got to. +/// +/// It is the same asymmetry the `red:` rows are read under, for the same reason. +/// A seat target and a route path are JOIN KEYS: they are resolved against +/// `laws.rs` and against testpak, and a key that names two things resolves +/// neither. A disposition's account joins nothing and is read by a person. +fn states_only_its_account(grammar: Grammar, account: &str) -> bool { + let stated: usize = match grammar { + // The file IS the claim: the row names it and stops. + Grammar::Route => 0, + // The target IS the claim, and a target is one token. + Grammar::Seat => 1, + // A sentence runs as long as it takes; see above. + Grammar::Disposition(_) => return true, + }; + account.split_whitespace().count() <= stated +} + +/// The `module::name` target a `laws.rs` row states, split where it splits. +/// +/// # A target is EXACTLY `module::law` +/// +/// One separator, and neither half empty. `root::a_law::extra` is not a deeper +/// target, it is two separators; `::a_law` names no module and `root::` names no +/// law, and each of those halves is a name the join resolves against. Split +/// looser — on the FIRST `::`, with nothing said about the rest — all three +/// became seats, and the empty-module one is a name `laws.rs` can actually +/// produce, because that file is read by tracking the module last opened at the +/// crate root and it starts as no module at all. +fn seat_target(account: &str) -> Option<(&str, &str)> { + let mut halves = account.split("::"); + let module = halves.next()?; + let law = halves.next()?; + if halves.next().is_some() || module.is_empty() || law.is_empty() { + return None; + } + Some((module, law)) +} + +/// Whether a disposition opens its account with `opener` and states something +/// after it. +/// +/// Something means a word: an opener followed by nothing, by its own closing +/// bracket, or by punctuation states the absence and accounts for none of it, +/// which is the half of the form that carries the whole meaning. +fn accounts_after(account: &str, opener: char) -> bool { + account + .strip_prefix(opener) + .is_some_and(|why| why.chars().any(char::is_alphanumeric)) +} + +/// Whether one green row's first word is a path to a Rust file. +/// +/// Read through `Path` rather than off the end of the string: a row states a +/// repository-relative path with forward slashes, and asking the path type for +/// its extension is the reading that stays right on either platform. +fn is_rust_route(named: &str) -> bool { + Path::new(named) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")) +} + +/// Planted reversals for the block reading and the row grammars. +/// +/// Every case is a fixture document held in memory: the reading that decides +/// which rows this repository publishes is never proven by editing a README the +/// repository stands on. +#[cfg(test)] +mod tests { + use super::{ + BlockSchema, MarkdownDocument, classify_green_row, obligation_ledger, phase_declaration, + tooling_reversal_rows, + }; + use crate::repository::types::{GreenRow, Read}; + + /// One seat carrying the target it named. + fn seat(module: &str, law: &str) -> GreenRow { + GreenRow::CompileTimeSeat { + module: String::from(module), + law: String::from(law), + } + } + + /// A document whose obligation ledger is written SECOND, after a phase + /// block, and whose prose carries a worked example of a row. + const TWO_BLOCKS_AND_A_WORKED_EXAMPLE: &str = "# Home\n\n\ + A row is written like this:\n\n\ + green: laws.rs bounds::a_row_nobody_declared\n\n\ + ```text\n\ + - id: bounds.an-example-nobody-declared\n\ + \x20 green: laws.rs bounds::also_nobody_declared\n\ + ```\n\n\ + ```yaml\n\ + phase: architecture-closure\n\ + toolchain: \"1.97.1\"\n\ + workspace_members:\n\ + \x20 - macros/macroc\n\ + \x20 - testpak\n\ + ```\n\n\ + ```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 - id: bounds.classes-are-closed\n\ + \x20 challenge_kind: compile-law\n\ + \x20 green: laws.rs bounds::classes_are_closed\n\ + \x20 red: owed-to-testpak\n\ + \x20 - id: bounds.a-stamped-roster\n\ + \x20 challenge_kind: compile-refusal\n\ + \x20 green: testpak/tests/stamp_row_ceiling.rs\n\ + \x20 red: testpak/tests/compile-fail/a-roster.rs\n\ + ```\n"; + + /// The block a reading is about is the one declaring its SCHEMA, wherever + /// it sits among the fences. + /// + /// Planted reversal for the first-fence rule. The phase block here is + /// written second among the yaml blocks in the file's own order and the + /// ledger third; a reading that took the first fenced yaml block would have + /// joined a ledger against the manifest as though it were a phase + /// declaration, and the day somebody inserted a block above would have been + /// the day it started doing that silently. + #[test] + fn a_block_is_chosen_by_its_schema_and_never_by_its_position() -> Result<(), String> { + let document = MarkdownDocument::parse(TWO_BLOCKS_AND_A_WORKED_EXAMPLE); + let phase = phase_declaration(&document).taken("the phase block")?; + assert_eq!(phase.toolchain(), "1.97.1"); + assert_eq!( + phase.members(), + [String::from("macros/macroc"), String::from("testpak")] + ); + let ledger = + obligation_ledger(&document, "home/README.md").taken("the obligation ledger")?; + assert_eq!(ledger.records().len(), 2, "{:?}", ledger.offences()); + Ok(()) + } + + /// Planted reversal: a row written in ordinary prose, and a record written + /// inside a fence that is not the data language. + /// + /// Both used to enter the published ledger, because the reading was a scan + /// of the WHOLE file that looked at no structure at all. The parser decides + /// what a block is, and the schema decides which block a reading is about, + /// so neither of these is reachable — and a writer who wants to DESCRIBE a + /// row can now do it. + #[test] + fn a_row_written_in_prose_reaches_nothing() -> Result<(), String> { + let document = MarkdownDocument::parse(TWO_BLOCKS_AND_A_WORKED_EXAMPLE); + let ledger = + obligation_ledger(&document, "home/README.md").taken("the obligation ledger")?; + assert!(ledger.offences().is_empty(), "{:?}", ledger.offences()); + let ids: Vec<&str> = ledger + .records() + .iter() + .map(|record| record.id.as_str()) + .collect(); + assert_eq!( + ids, + vec!["bounds.classes-are-closed", "bounds.a-stamped-roster"], + "a record nobody declared entered the ledger" + ); + assert!( + ledger + .records() + .iter() + .all(|record| record.green.len() == 1 && record.red.len() == 1), + "a record lost or gained a row" + ); + Ok(()) + } + + /// Every row lands in the record whose own item wrote it, and a record that + /// lost a row carries none. + /// + /// Planted reversal for the two independent whole-file scans this replaced: + /// with its `green:` line deleted an obligation named no positive control, + /// so none was resolved and it qualified on its red row alone; with its + /// `red:` line deleted the published denominator shrank by one with nothing + /// saying so. + #[test] + fn a_record_that_lost_a_row_carries_none() -> Result<(), String> { + let document = MarkdownDocument::parse( + "```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 - id: bounds.no-route-at-all\n\ + \x20 red: owed-to-testpak\n\ + \x20 - id: bounds.no-reversal-at-all\n\ + \x20 green: laws.rs bounds::budget_is_affine\n\ + ```\n", + ); + let ledger = + obligation_ledger(&document, "home/README.md").taken("the obligation ledger")?; + assert_eq!(ledger.records().len(), 2); + assert!( + ledger + .records() + .first() + .is_some_and(|record| record.green.is_empty() && record.red.len() == 1), + "the record that lost its green row is not the record carrying none" + ); + assert!( + ledger + .records() + .last() + .is_some_and(|record| record.green.len() == 1 && record.red.is_empty()), + "the record that lost its red row is not the record carrying none" + ); + Ok(()) + } + + /// Planted reversal: rows written where no record owns them, and an item + /// that opens with something other than an identity. + /// + /// The reading's own failure mode, refused rather than trusted. A row no + /// record carries is a row the join never joins, so losing one quietly would + /// be the silence this whole model was built to end. + #[test] + fn a_row_no_record_owns_is_refused() -> Result<(), String> { + let document = MarkdownDocument::parse( + "```yaml\n\ + home: bounds\n\ + obligations:\n\ + \x20 green: laws.rs bounds::a_row_above_every_record\n\ + \x20 - challenge_kind: compile-law\n\ + \x20 green: laws.rs bounds::a_row_under_a_nameless_item\n\ + ```\n", + ); + let ledger = + obligation_ledger(&document, "home/README.md").taken("the obligation ledger")?; + // Three: the row above every item, the item that opens with no + // identity, and the row written beneath that item — which no record owns + // either, because the item that would have owned it opened nothing. + assert_eq!(ledger.offences().len(), 3, "{:?}", ledger.offences()); + assert!( + ledger + .offences() + .iter() + .any(|offence| offence.contains("stands outside every obligation record")), + "{:?}", + ledger.offences() + ); + assert!( + ledger + .offences() + .iter() + .any(|offence| offence.contains("opens with `challenge_kind:`")), + "{:?}", + ledger.offences() + ); + Ok(()) + } + + /// A document declaring one schema TWICE is refused rather than resolved by + /// position. + #[test] + fn two_blocks_declaring_one_schema_are_refused() { + let document = MarkdownDocument::parse( + "```yaml\nhome: a\nobligations:\n```\n\n```yaml\nhome: b\nobligations:\n```\n", + ); + let found = obligation_ledger(&document, "home/README.md"); + assert!( + matches!(found, Read::Unreadable(_)), + "two ledgers in one document resolved to one" + ); + } + + /// A data block declaring no schema this repository reads is REPORTED + /// rather than skipped, so a ledger that quietly stopped being recognized is + /// something a law can refuse. + #[test] + fn a_data_block_declaring_no_schema_is_reported() { + let document = MarkdownDocument::parse("```yaml\nsomething: else\nentirely: true\n```\n"); + let unjoinable = document.unjoinable_data_blocks(); + assert_eq!(unjoinable.len(), 1, "{unjoinable:?}"); + assert!( + unjoinable + .first() + .is_some_and(|offence| offence.contains("declares no schema")), + "{unjoinable:?}" + ); + assert!(matches!( + document.block(BlockSchema::ObligationLedger), + Read::DeclaredAbsent(_) + )); + } + + /// Planted reversal: one data block declaring TWO schemas. + /// + /// The reading this replaced asked its four questions in declaration order + /// and returned the first that said yes, so this block was an obligation + /// ledger — not because the document said so, but because that arm was + /// written above the phase arm. The phase reader further down the file then + /// found nothing, and the block it was looking at was already answering to + /// somebody else. Every schema is calculated now, exactly one is required, + /// and BOTH readers are refused by name rather than one of them winning. + #[test] + fn one_block_declaring_two_schemas_is_refused_by_both_readers() { + let document = MarkdownDocument::parse( + "```yaml\n\ + phase: architecture-closure\n\ + toolchain: \"1.97.1\"\n\ + obligations:\n\ + \x20 - id: bounds.a-record\n\ + ```\n", + ); + assert!( + matches!( + document.block(BlockSchema::ObligationLedger), + Read::Unreadable(_) + ), + "the block was won by the reader declared first" + ); + assert!( + matches!( + document.block(BlockSchema::PhaseDeclaration), + Read::Unreadable(_) + ), + "the reader that lost the race was told the schema is simply absent" + ); + let unjoinable = document.unjoinable_data_blocks(); + assert_eq!(unjoinable.len(), 1, "{unjoinable:?}"); + assert!( + unjoinable.first().is_some_and(|offence| { + offence.contains("2 schemas at once") + && offence.contains("the phase declaration block") + && offence.contains("the obligation ledger block") + }), + "a refusal that cannot name the schemas tells nobody what to delete: {unjoinable:?}" + ); + } + + /// The positive control: a block writing one schema's keys declares that + /// schema and no other, and a key another schema also happens to want is + /// not enough on its own. + /// + /// `seat` and `state` TOGETHER identify a reservation. A block writing only + /// one of them declares no schema rather than half of one, which is what + /// keeps the two-schema refusal from firing on every document that happens + /// to write a common word. + #[test] + fn a_schema_is_declared_by_all_of_its_keys_and_by_no_fewer() { + let whole = MarkdownDocument::parse("```yaml\nseat: 09_port\nstate: reserved\n```\n"); + assert!(whole.unjoinable_data_blocks().is_empty()); + assert!(matches!( + whole.block(BlockSchema::SeatReservation), + Read::Known(_) + )); + let half = MarkdownDocument::parse("```yaml\nseat: 09_port\n```\n"); + assert_eq!(half.unjoinable_data_blocks().len(), 1); + assert!(matches!( + half.block(BlockSchema::SeatReservation), + Read::DeclaredAbsent(_) + )); + } + + /// The tooling ledger's rows are read out of the block that declares them, + /// and a `tooling-red:` written in that document's prose is not one. + #[test] + fn a_tooling_row_is_read_out_of_its_own_block() -> Result<(), String> { + let document = MarkdownDocument::parse( + "prose mentioning tooling-red: not-a-row.rs\n\n\ + ```yaml\n\ + tooling-obligation: macroc.one\n\ + \x20 claim: >\n\ + \x20 a sentence\n\ + \x20 tooling-red: testpak/tests/planted_defect.rs\n\ + \n\ + tooling-obligation: macroc.two\n\ + \x20 tooling-red: owed-to-testpak — a renderer hardcoding the binding\n\ + ```\n", + ); + let rows = tooling_reversal_rows(&document).taken("the tooling ledger")?; + assert_eq!( + rows, + vec![ + String::from("testpak/tests/planted_defect.rs"), + String::from("owed-to-testpak — a renderer hardcoding the binding"), + ] + ); + Ok(()) + } + + /// The positive control: every spelling this repository writes is read as + /// the spelling it is. + #[test] + fn every_lawful_green_spelling_is_read_as_itself() { + assert_eq!( + classify_green_row("laws.rs root::a_seat_that_exists"), + seat("root", "a_seat_that_exists") + ); + assert_eq!( + classify_green_row("none — the type's nonexistence is what refuses"), + GreenRow::Disposition + ); + assert_eq!( + classify_green_row("owed — executable when the roster lands"), + GreenRow::Disposition + ); + assert_eq!( + classify_green_row("structural (a phantom makes the handle !Send)"), + GreenRow::Disposition + ); + assert_eq!( + classify_green_row("testpak/tests/stamp_row_ceiling.rs"), + GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")) + ); + } + + /// Planted reversal: a row carrying a token AFTER its account, on both join + /// keys. + /// + /// The row resolves a real law or a real seat, so every join leg downstream + /// says yes and the obligation qualifies. What it says beyond its account + /// was simply thrown away — a second target somebody meant to add, a stray + /// note, half of a finished rename. A green account is exactly what its + /// grammar defines, and a row that says more says something this repository + /// does not read. + #[test] + fn a_row_carrying_more_than_its_account_is_unreadable() { + for value in [ + "laws.rs root::reading_is_not_gaining extra", + "laws.rs root::reading_is_not_gaining\troot::closure_bar_is_implementable", + "testpak/tests/stamp_row_ceiling.rs missing-control.rs", + "testpak/tests/stamp_row_ceiling.rs testpak/tests/stamp_row_ceiling.rs", + ] { + assert!( + matches!(classify_green_row(value), GreenRow::Unreadable(_)), + "`{value}` was truncated into a claim its author did not make" + ); + } + } + + /// Planted reversal: a target that is not exactly `module::law`, in all four + /// malformed spellings, and a disposition that states the absence and + /// withholds the account of it. + #[test] + fn a_malformed_account_is_unreadable() { + for value in [ + "laws.rs root::reading_is_not_gaining::extra", + "laws.rs ::reading_is_not_gaining", + "laws.rs root::", + "laws.rs root", + "none", + "owed", + "none - a hyphen is not the declared separator", + "structural ()", + "owed —", + "", + "laws.rs", + "testpak/tests/stamp_row_ceiling.r", + ] { + assert!( + matches!(classify_green_row(value), GreenRow::Unreadable(_)), + "`{value}` was read as a claim" + ); + } + } + + /// The disposition account is PROSE and is held to no token ceiling, and + /// this is the control that says the asymmetry is a decision rather than the + /// case a pass over the class forgot. + #[test] + fn a_disposition_account_carries_no_token_ceiling() { + assert_eq!( + classify_green_row( + "none — no family payload can carry a spelling, skeleton, or scalar" + ), + GreenRow::Disposition + ); + assert_eq!( + classify_green_row("structural (raw-pointer phantom makes the handle !Send and !Sync)"), + GreenRow::Disposition + ); + } +} diff --git a/xtask/src/repository/mod.rs b/xtask/src/repository/mod.rs index 560caaa..2655ff0 100644 --- a/xtask/src/repository/mod.rs +++ b/xtask/src/repository/mod.rs @@ -1,18 +1,25 @@ //! Reading the repository. //! //! Nothing here decides whether the repository is lawful. These modules turn the -//! tree and the files in it into facts — paths, manifest entries, README rows — -//! and `crate::checks` judges those facts. Splitting the reading from the -//! judging is what lets a law be proven against fixture text instead of against -//! the tree it guards. +//! tree into FACTS — one immutable snapshot, built once — and `crate::checks` +//! judges those facts. Splitting the reading from the judging is what lets a law +//! be proven against fixture text instead of against the tree it guards. +//! +//! Each module is authoritative for one language and claims nothing outside it: +//! [`cargo`] for Cargo's syntax and for what cargo normalizes those manifests +//! to, [`markdown`] for document structure, [`rust`] for Rust syntax. +//! [`snapshot`] is the one place that touches the filesystem or starts a +//! process; [`types`] is the vocabulary the readings and the laws share. //! //! The modules are declared in dependency order: the shared vocabulary, then the -//! walker every reader stands on, then the two file readers. +//! snapshot every reading is carried in, then the three decoders. pub(crate) mod types; -pub(crate) mod walk; +pub(crate) mod snapshot; + +pub(crate) mod cargo; -pub(crate) mod manifest; +pub(crate) mod markdown; -pub(crate) mod readme; +pub(crate) mod rust; diff --git a/xtask/src/repository/readme.rs b/xtask/src/repository/readme.rs deleted file mode 100644 index 4d0686e..0000000 --- a/xtask/src/repository/readme.rs +++ /dev/null @@ -1,1032 +0,0 @@ -//! Reading a home README. -//! -//! A home README is markdown prose plus fenced yaml blocks and obligation rows -//! that tooling parses. These readers turn those blocks and rows into values and -//! stop there — the joins that decide whether the values agree with the tree -//! live in `crate::checks::obligations` and `crate::checks::toolchain`. - -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::repository::types::{GreenRow, ObligationRecord}; - -/// The lines inside the README's fenced yaml block. -/// -/// # It is the FIRST such block, and that is a named ceiling -/// -/// The block is chosen by POSITION: this opens at the first fence line carrying -/// the yaml language tag and closes at the next bare fence. The root README -/// writes two yaml blocks — the phase and workspace declaration this reader -/// wants, and the root calculus's own obligation rows — and this reader gets the -/// right one because the right one happens to be written first. That is a fact -/// about the file's current order rather than about the reader, so it fails OPEN -/// the day the order changes: a block inserted above would be read as the -/// toolchain and member declaration and joined against the manifest as though it -/// were one. -/// -/// It is the same missing reading [`classify_green_rows`] states its own ceiling -/// on, and it opens on the same condition — a Markdown parser and typed fenced -/// blocks selected by SCHEMA rather than by position. Teaching this one to count -/// fences would close the position case and leave the schema case standing, in a -/// second reader that would then have to agree with the first about what a block -/// is. -pub(crate) fn readme_yaml_block(root: &Path) -> Result, String> { - let readme = - fs::read_to_string(root.join("README.md")).map_err(|e| format!("README.md: {e}"))?; - let mut lines = Vec::new(); - let mut inside = false; - for line in readme.lines() { - if inside { - if line.trim() == "```" { - return Ok(lines); - } - lines.push(line.to_string()); - } else if line.trim() == "```yaml" { - inside = true; - } - } - Err(String::from("README.md has no fenced yaml block")) -} - -/// Every home README the join reads: the root one, and one per numbered band. -pub(crate) fn home_readmes(root: &Path) -> Result, String> { - let mut readmes = vec![root.join("README.md")]; - let src = root.join("src"); - let entries = fs::read_dir(&src).map_err(|e| format!("{}: {e}", src.display()))?; - for entry in entries { - let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?; - let candidate = entry.path().join("README.md"); - if candidate.is_file() { - readmes.push(candidate); - } - } - Ok(readmes) -} - -/// The spelling a green row opens with when its positive control is a -/// compile-time seat. -const COMPILE_TIME_SEAT: &str = "laws.rs"; - -/// The separator a `none` or `owed` disposition states its account after, as -/// every such row in this repository is written. -const DISPOSITION_DASH: char = '—'; - -/// The opener a `structural` disposition states its account inside. -const DISPOSITION_PAREN: char = '('; - -/// The grammar one green row is written in, chosen by the word the row opens -/// with. -/// -/// Three grammars, and being none of them is the absence of one. This exists so -/// that the ceiling on an account — what a row may state, and where it must stop -/// — is asked of the ROW's grammar in one place, instead of being remembered -/// inside whichever branch happens to build the row. Three branches each -/// carrying their own ceiling is three chances to forget one, and the history of -/// this reader is exactly that: the rule was written for the seat branch, the -/// route branch went on taking its first token and discarding the rest, and the -/// defect regrew one branch to the right. A rule applied per site regrows one -/// site over; a rule applied to the class does not. -/// -/// Private to this reader. It is not vocabulary two families share — nothing -/// outside these few functions has an opinion about how a green row is spelled — -/// so it is not in `types.rs`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Grammar { - /// `laws.rs module::law`: the account is the target, and a target is one - /// token. - Seat, - /// `path/to/file.rs`: the opening word IS the claim, so the account is - /// silent — nothing at all follows the path. - Route, - /// `none — …`, `owed — …`, `structural (…)`: the account is a SENTENCE, - /// opened by this character, and it runs for as long as the sentence takes. - Disposition(char), -} - -/// Every `green:` obligation row in one README, classified, in file order. -/// -/// THE prefix discipline for green rows, and the only one: this reader is the -/// whole population, so nothing downstream matches a prefix of its own. -/// -/// The prefix is matched WITHOUT its trailing space and the value is trimmed -/// after, so a row whose value was emptied is read as a row with an empty value -/// rather than vanishing from the population — and, for the same reason, a row -/// spelled `green:laws.rs …`, or with a tab, or with two spaces, is read as the -/// row it plainly is. A second reader matching `"green: laws.rs "` dropped every -/// one of those while this one seated them, and a row seated by one reader and -/// claimed by neither is an obligation that qualifies while naming a law nobody -/// wrote. There is no second reader now: the seat carries its target, so the -/// spacing a row happens to be written with cannot decide whether its claim is -/// joined. -/// -/// # The scan is the WHOLE file, and that is a named ceiling -/// -/// A row is found by reading every line of the README, so a line beginning -/// `green:` outside an obligation block — in prose, in a `text` fence, in a -/// worked example showing what a row looks like — is read as an obligation row -/// and joined like one. Nothing about a fence, a heading, or a block is looked -/// at here, because nothing in this file reads Markdown structure at all. -/// -/// It fails LOUDLY rather than open, and that is why it is a ceiling rather than -/// a defect being carried quietly. An invented row is classified like every -/// other and then answered: unreadable where no grammar reads it, resolved -/// against `laws.rs` and testpak where one does, and named against the README -/// that wrote it wherever it does not resolve. Nothing reads as proven that is -/// not. What the ceiling costs is a writer who wanted to DESCRIBE a row without -/// declaring one — and the tree pays that price today rather than suffering -/// from it: no line beginning `green:` is written outside an obligation block -/// anywhere in the homes this reader is given. -/// -/// It is not closed here, and the reason is the SHAPE of the repair rather than -/// its size. Restricting the scan to the obligation blocks means knowing where -/// those blocks are, which means reading Markdown structure — and this file -/// already carries that same missing reading a second time, in -/// [`readme_yaml_block`], which takes the first fenced block and calls it the -/// one it wanted. A fence-tracking line scan here would be one more ad-hoc -/// reader in a file whose entire defect history is ad-hoc readers, and it would -/// have to be written twice or shared between two readers that want different -/// blocks. The opening condition is the typed repository model: one Markdown -/// parser, typed fenced data blocks selected by SCHEMA rather than by position, -/// and one versioned obligation schema read out of them. Both readings close -/// there, together, and neither closes here. -/// -/// The exposure is stated above and deliberately NOT asserted. A control over it -/// would have to find the obligation blocks to know which rows are outside one, -/// which is exactly the reading this ceiling is waiting for — a test that built -/// the thing it is waiting for would be that thing, arriving through the test -/// file instead of through the reader. -pub(crate) fn classify_green_rows(readme_text: &str) -> Vec { - readme_text - .lines() - .filter_map(|line| line.trim().strip_prefix("green:")) - .map(|value| classify_green_row(value.trim())) - .collect() -} - -/// One green row's value, classified. Nothing is dropped: a value no lawful -/// spelling reads comes back as [`GreenRow::Unreadable`] carrying itself. -/// -/// The row's grammar is decided first, its account is held to that grammar's -/// ceiling second, and only then is a row built. The ceiling is applied HERE, to -/// every grammar at once, rather than inside the arms below: an arm can only -/// interpret an account this function has already agreed is the whole of what -/// the row stated, so there is no arm left that could read past its own account. -fn classify_green_row(value: &str) -> GreenRow { - let Some(opening) = value.split_whitespace().next() else { - return GreenRow::Unreadable(value.to_string()); - }; - let account = value.strip_prefix(opening).unwrap_or_default().trim(); - let read = green_grammar(opening) - .filter(|&grammar| states_only_its_account(grammar, account)) - .and_then(|grammar| match grammar { - Grammar::Seat => seat_target(account).map(|(module, law)| GreenRow::CompileTimeSeat { - module: module.to_string(), - law: law.to_string(), - }), - Grammar::Route => Some(GreenRow::Route(opening.to_string())), - Grammar::Disposition(opener) => { - accounts_after(account, opener).then_some(GreenRow::Disposition) - } - }); - read.unwrap_or_else(|| GreenRow::Unreadable(value.to_string())) -} - -/// Which grammar a green row's opening word puts it in, or none where the word -/// opens no grammar this repository reads. -/// -/// The seat is asked about BEFORE the route, and the order is load-bearing: -/// `laws.rs` is itself a path to a Rust file, so a reader that asked the route -/// question first would read every one of this repository's seat rows as a route -/// naming a file at the repository root and demand a test binary of it. The -/// opening word alone decides the grammar; nothing after it is looked at here, -/// because what a row may say after its opening word is the next question and -/// has one answer for the whole class. -fn green_grammar(opening: &str) -> Option { - if opening == COMPILE_TIME_SEAT { - Some(Grammar::Seat) - } else if opening == "none" || opening == "owed" { - Some(Grammar::Disposition(DISPOSITION_DASH)) - } else if opening == "structural" { - Some(Grammar::Disposition(DISPOSITION_PAREN)) - } else if is_rust_route(opening) { - Some(Grammar::Route) - } else { - None - } -} - -/// Whether a green row states the account its grammar defines and NOTHING after -/// it. -/// -/// THE ceiling on a green account, and the only one. Whatever kind of green row -/// it is, the account is exactly the tokens that row's grammar defines, and a -/// token past them makes the row [`GreenRow::Unreadable`]. One statement, over -/// the class — because it has been stated per branch, and per branch it only -/// ever held for the branch written last. A seat row carrying a word after its -/// target was closed inside the seat reader, and a route row carrying a word -/// after its path went on resolving the real file and qualifying while the rest -/// of what it stated was thrown away. The defect did not survive that repair; it -/// moved one branch over. -/// -/// What a row said beyond its account is never the point. A second target -/// somebody meant to add, a note, half of a finished rename, a path that was -/// supposed to replace the one in front of it — the row said something this -/// repository does not read, and a reader that truncates it silently converts it -/// into a claim its author did not make. Named against the README that wrote it, -/// the author says what they meant instead. -/// -/// # The disposition grammar has no ceiling, and that is a decision -/// -/// A `none`, `owed`, or `structural` row accounts for why NO file holds a -/// positive control, and an account of that kind is prose: eight to twelve words -/// in every such row this repository has written, some of them running on across -/// a wrapped line. Prose legitimately has many tokens, so there is no number to -/// hold it to and this rule admits it without one — deliberately, and stated -/// here rather than left as the case nobody got to. -/// -/// It is the same asymmetry the `red:` rows are read under, for the same reason. -/// A seat target and a route path are JOIN KEYS: they are resolved against -/// `laws.rs` and against testpak, and a key that names two things resolves -/// neither. A disposition's account joins nothing and is read by a person. -/// Holding the sentences to the keys' rule would unread every disposition row in -/// this tree; holding the keys to the sentences' rule is the defect this -/// function refuses. -fn states_only_its_account(grammar: Grammar, account: &str) -> bool { - let stated: usize = match grammar { - // The file IS the claim: the row names it and stops. - Grammar::Route => 0, - // The target IS the claim, and a target is one token. - Grammar::Seat => 1, - // A sentence runs as long as it takes; see above. - Grammar::Disposition(_) => return true, - }; - account.split_whitespace().count() <= stated -} - -/// The `module::name` target a `laws.rs` row states, split where it splits. -/// -/// The one place a green target is read. The split happens here rather than -/// downstream so that a seat and its claim are the same act: every row this -/// function splits becomes a seat carrying that exact pair, and every row it -/// cannot split names no target at all and leaves as [`GreenRow::Unreadable`], -/// answered by the leg that names it against the README that wrote it. Neither -/// outcome depends on how the row was spaced, and no later reader gets a second -/// opinion about which characters the target was made of. -/// -/// The account arrives already held to one token by -/// [`states_only_its_account`], so a target is never truncated out of a longer -/// account here — this function reads what the row stated, whole. -/// -/// # A target is EXACTLY `module::law` -/// -/// One separator, and neither half empty. `root::a_law::extra` is not a deeper -/// target, it is two separators; `::a_law` names no module and `root::` names no -/// law, and each of those halves is a name the join resolves against. Split -/// looser — on the FIRST `::`, with nothing said about the rest — all three -/// became seats: `root::a_law::extra` seated the module `root` with a law called -/// `a_law::extra`, and `::a_law` seated a law under a module whose name is the -/// empty string. -/// -/// That last one is not merely a wrong-looking pair. `laws.rs` is read by -/// tracking the module last opened at the crate root, which begins as no module -/// at all, so a `#[test]` written above the first `mod` would be declared under -/// exactly that empty name — and a row spelled `laws.rs ::that_law` would then -/// resolve to it and qualify. Today no such law is written and all three -/// spellings are refused downstream instead, by the leg that reports a claim on -/// a law `laws.rs` does not have: a real refusal, on the wrong subject, sending -/// the author to `laws.rs` when the repair is in the README's own row. Read -/// here, the row is named against the README that wrote it, which is how every -/// other unreadable row in this repository is answered, and the door the empty -/// module left open is shut before anything walks through it. -/// -/// The RED rows are a DIFFERENT grammar and are deliberately not held to any of -/// this. A `red:` or `tooling-red:` row names its reversal and then continues in -/// prose, across wrapped lines, as this repository's tooling ledgers are -/// written; that convention is documented where those rows are declared and it -/// stays. A green compile-time target is not prose with a path in front of it, -/// and reading the two the same way would either silence this row or break every -/// one of those. -fn seat_target(account: &str) -> Option<(&str, &str)> { - let mut halves = account.split("::"); - let module = halves.next()?; - let law = halves.next()?; - if halves.next().is_some() || module.is_empty() || law.is_empty() { - return None; - } - Some((module, law)) -} - -/// Whether a disposition opens its account with `opener` and states something -/// after it. -/// -/// Something means a word: an opener followed by nothing, by its own closing -/// bracket, or by punctuation states the absence and accounts for none of it, -/// which is the half of the form that carries the whole meaning. -fn accounts_after(account: &str, opener: char) -> bool { - account - .strip_prefix(opener) - .is_some_and(|why| why.chars().any(char::is_alphanumeric)) -} - -/// Whether one green row's first word is a path to a Rust file. -/// -/// Read through `Path` rather than off the end of the string: a row states a -/// repository-relative path with forward slashes, and asking the path type for -/// its extension is the reading that stays right on either platform. -fn is_rust_route(named: &str) -> bool { - Path::new(named) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")) -} - -/// The value of every `red:` obligation row in one README, in file order. -/// -/// The prefix is matched on the TRIMMED line, so a word merely ending in `red` -/// followed by a colon — `unnumbered:`, `authored:`, `Shred:` — is never a row. -/// It is matched WITHOUT its trailing space for the reason the green side is: a -/// row whose value was emptied is still a row, and a reader that stopped seeing -/// it would quietly shrink the denominator this repository publishes. -/// -/// THE prefix discipline for red rows, and the only one, on the same rule the -/// green side is held to: one population gets one reader. Two readers over these -/// rows would not merely disagree, they would disagree about a published number, -/// and the row each of them dropped would be the row nobody looked at. -/// -/// The whole value is carried, prose and all. A red row NAMES its reversal and -/// then says what the reversal does, wrapped across as many lines as the -/// sentence takes — `owed-to-testpak — cloning a Budget must not compile` is the -/// same grammar as a row naming a fixture and then describing it. The ledger -/// reads the name off the front and the prose is for the reader. That is -/// deliberately NOT the green compile-time grammar, where the target is one -/// token and a second token makes the row unreadable: a green seat is a join key -/// and a red row is a sentence, and holding either to the other's rule would -/// break every ledger row this repository has written. -pub(crate) fn red_twin_rows(readme_text: &str) -> Vec { - readme_text - .lines() - .filter_map(|line| line.trim().strip_prefix("red:")) - .map(|value| value.trim().to_string()) - .collect() -} - -/// The value of every `tooling-red:` obligation row in one README, in file -/// order. -/// -/// Read exactly like a core `red:` row — same prefix discipline, same emptied -/// row still read, same name-then-prose grammar — and counted on its own ledger. -/// An `owed-to-…` row is a lawful debt; any other row NAMES a reversal that must -/// resolve to a real testpak test or compile-fail fixture, and the check refuses -/// it if it does not. -/// -/// Several of the tooling ledgers' rows state what their reversal does after -/// naming it, and one of them names sibling fixtures in that prose. The ledger -/// resolves the first token and reads none of the rest, which is a stated -/// ceiling rather than a convention: a row carrying more than one fixture -/// reference has exactly one of them joined, and the others are prose no check -/// looks at. Closing that takes typed fixture references in the row itself, -/// which the versioned claim and evidence schema opens; it is not closed by -/// splitting a sentence on whitespace. -pub(crate) fn tooling_red_rows(readme_text: &str) -> Vec { - readme_text - .lines() - .filter_map(|line| line.trim().strip_prefix("tooling-red:")) - .map(|value| value.trim().to_string()) - .collect() -} - -/// The line an obligation record opens with, as every home README writes one. -const RECORD_OPENER: &str = "- id:"; - -/// A record being read: what it opened as, how deep it opened, and the lines it -/// has carried so far. -/// -/// It never leaves this reader. What the rest of the repository is handed is an -/// [`ObligationRecord`], whose rows are already read. -struct OpenRecord { - /// The identity the opening line stated. - id: String, - /// The indentation the opening line stood at. Its fields stand deeper. - depth: usize, - /// The lines this record has carried, as they were written. - carried: String, -} - -/// Every obligation RECORD one README declares, in file order, each carrying the -/// rows its own block wrote. -/// -/// A record opens at `- id:` and carries every following line indented DEEPER -/// than the line that opened it; the next opener, or any line back at or above -/// that indentation, closes it. That is the shape these blocks are written in, -/// and it is the whole of the structure this reader knows. -/// -/// The rows are read by the readers that already read rows. This function's -/// entire job is to decide WHICH lines each record owns and then hand those -/// lines to [`classify_green_rows`] and [`red_twin_rows`] — the same two -/// functions, over a narrower text. Nothing here matches a prefix, splits a -/// value, or decides what a row means: a second reading of a row is the defect -/// this file's whole history is made of, and grouping is not reading. -/// -/// # What the grouping closed -/// -/// The join used to gather `green:` and `red:` rows by two independent scans of -/// the whole file, with nothing binding a row to the obligation that declared -/// it. Delete an obligation's `green:` line and the record stated no positive -/// control at all: no route existed, so no route was resolved, and the -/// obligation qualified on its `red:` row alone. Delete its `red:` line and the -/// published core denominator shrank by one with nothing saying so. Neither -/// deletion could be seen by a reader whose subject was a ROW, because the -/// missing row is exactly the thing a row-shaped reader has nothing to say -/// about — unlike a `laws.rs` claim, which an orphaned law exposes from the -/// other side. -/// -/// # The ceiling, and which way it falls -/// -/// This reads a record by its declared FIELD STRUCTURE — an opener and the -/// indentation beneath it — and not by parsing the fenced block as the yaml -/// document it is written as. So a `- id:` line written in prose opens a record -/// that is not one, and a record whose fields are not indented deeper than their -/// opener carries none of them. -/// -/// Both fail LOUDLY. An invented record carries no rows and is refused for -/// carrying none; a record whose fields escaped it leaves those rows owned by -/// nobody, and the join names them against the README that wrote them. There is -/// no direction in which a mis-grouped record reads as whole. What it costs is a -/// writer who wanted to DESCRIBE a record without declaring one, and the tree -/// pays that today rather than suffering from it. -/// -/// What it does NOT establish is that two records state two different ids: an id -/// is a join key only where the record routes, and nothing here refuses a -/// repeated one. The permanent seat for all of it is the same one -/// [`classify_green_rows`] and [`readme_yaml_block`] are waiting on — one -/// Markdown parser, typed fenced blocks selected by SCHEMA rather than by -/// position, and one versioned claim and evidence schema read out of them, where -/// a record is a value with named fields rather than a shape a reader infers. -pub(crate) fn obligation_records(readme_text: &str) -> Vec { - let mut closed: Vec = Vec::new(); - let mut open: Option = None; - for line in readme_text.lines() { - let trimmed = line.trim(); - if let Some(stated) = trimmed.strip_prefix(RECORD_OPENER) { - closed.extend(open.replace(OpenRecord { - id: stated.trim().to_string(), - depth: indentation(line), - carried: String::new(), - })); - } else if trimmed.is_empty() { - // A blank line states nothing, so it neither carries a row nor - // decides where a record ends. - } else if open - .as_ref() - .is_some_and(|record| indentation(line) > record.depth) - { - if let Some(record) = open.as_mut() { - record.carried.push_str(line); - record.carried.push('\n'); - } - } else { - closed.extend(open.take()); - } - } - closed.extend(open); - closed - .into_iter() - .map(|record| ObligationRecord { - id: record.id, - green: classify_green_rows(&record.carried), - red: red_twin_rows(&record.carried), - }) - .collect() -} - -/// How deep one line stands: the width of the whitespace it opens with, as -/// written. -/// -/// Compared against another line's, never against a number. What decides a -/// record's extent is that its fields stand deeper than its opener, and that -/// relation holds however wide the file's indentation happens to be. -fn indentation(line: &str) -> usize { - line.len().saturating_sub(line.trim_start().len()) -} - -#[cfg(test)] -mod tests { - use super::{classify_green_rows, obligation_records, red_twin_rows, tooling_red_rows}; - use crate::repository::types::GreenRow; - - /// A row is read off the trimmed line, so an ordinary word ending in `red` - /// followed by a colon is never mistaken for one. - #[test] - fn only_a_red_row_is_a_red_row() { - let text = "unnumbered: first-class, not a row\n\ - Shred: the four progress facts, not a row\n\ - ## The connectives (authored: a heading, not a row)\n\ - \x20 red: owed-to-testpak\n"; - assert_eq!(red_twin_rows(text), vec![String::from("owed-to-testpak")]); - } - - /// Planted reversal: a `red:` and a `tooling-red:` row whose value was - /// emptied. Read on the trailing space, neither line was a row at all, and - /// the obligation left the denominator without anything refusing. - /// - /// The red side's twin of the green side's dropped route, and the more - /// expensive of the two: the core and tooling red counts are PUBLISHED on - /// every run, so a row that stops being read shrinks a number the campaign - /// reports rather than merely going unchecked. - #[test] - fn an_emptied_red_row_is_still_a_row() { - let text = " red:\n\ - \x20 tooling-red:\n\ - \x20 red: owed-to-testpak\n"; - assert_eq!( - red_twin_rows(text), - vec![String::new(), String::from("owed-to-testpak")] - ); - assert_eq!(tooling_red_rows(text), vec![String::new()]); - } - - /// One seat carrying the target it named. - fn seat(module: &str, law: &str) -> GreenRow { - GreenRow::CompileTimeSeat { - module: String::from(module), - law: String::from(law), - } - } - - /// The positive control: every spelling this repository actually writes is - /// read as the spelling it is, and the route population is exactly the rows - /// naming a file. - /// - /// A reader that also returned the `laws.rs` rows as routes would demand a - /// test FILE from a row whose control is a compile-time seat, and one that - /// swallowed the prose dispositions would demand a file from a row whose - /// whole content is that no file exists. - #[test] - fn every_lawful_green_spelling_is_read_as_itself() { - let text = " green: laws.rs root::a_seat_that_exists\n\ - \x20 green: none — the type's nonexistence is what refuses\n\ - \x20 green: owed — executable when the roster lands\n\ - \x20 green: structural (a phantom makes the handle !Send)\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs\n"; - assert_eq!( - classify_green_rows(text), - vec![ - seat("root", "a_seat_that_exists"), - GreenRow::Disposition, - GreenRow::Disposition, - GreenRow::Disposition, - GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")), - ] - ); - } - - /// Planted reversal: four seat rows whose only fault is how they were - /// spaced. A SECOND reader matching the literal `"green: laws.rs "` dropped - /// every one of them — no space after the colon, two spaces, a tab, a tab - /// between `laws.rs` and its target — while this reader seated all four. - /// - /// That gap is the whole defect, and it was silent by construction: the - /// dropped rows never reached the join, so the leg that refuses a claim on a - /// law nobody wrote never saw them, and the count of rows READ still agreed - /// with the count of rows WRITTEN because the classifier had counted them. - /// An obligation could therefore qualify while naming a law that does not - /// exist, on nothing but a keystroke of whitespace. Every one of these now - /// carries the same target as the row spelled the ordinary way, so the join - /// resolves them all. - #[test] - fn a_seat_row_is_read_however_it_is_spaced() { - let text = " green:laws.rs root::a_seat_that_exists\n\ - \x20 green: laws.rs root::a_seat_that_exists\n\ - \x20 green:\tlaws.rs root::a_seat_that_exists\n\ - \x20 green: laws.rs\troot::a_seat_that_exists\n"; - let read = classify_green_rows(text); - assert_eq!( - read, - vec![ - seat("root", "a_seat_that_exists"), - seat("root", "a_seat_that_exists"), - seat("root", "a_seat_that_exists"), - seat("root", "a_seat_that_exists"), - ], - "{read:?}" - ); - } - - /// Planted reversal: a seat row whose target is not `module::law`, in the - /// two ways it goes wrong — one colon, and a name with no module in front - /// of it. - /// - /// Both used to be read by a second reader that answered a MALFORMED target - /// by failing the whole join with a message about the target alone. Read - /// here, the row is named against the README that wrote it and the rest of - /// the population is still judged, which is how every other unreadable row - /// in this repository is answered. - #[test] - fn a_seat_without_a_module_law_target_is_unreadable() { - let text = " green: laws.rs root:a_seat_that_exists\n\ - \x20 green: laws.rs a_seat_that_exists\n"; - assert_eq!( - classify_green_rows(text), - vec![ - GreenRow::Unreadable(String::from("laws.rs root:a_seat_that_exists")), - GreenRow::Unreadable(String::from("laws.rs a_seat_that_exists")), - ] - ); - } - - /// Planted reversal: a seat row carrying a token AFTER its target. - /// - /// The row resolves a real law — `root::reading_is_not_gaining` is written - /// in the root README and declared in `laws.rs` — so every join leg downstream - /// says yes and the obligation qualifies. What it says beyond the target was - /// simply thrown away: the reader took the first token of the account and - /// dropped the rest, so a second target somebody meant to add, a stray note, - /// or half of a finished rename all read as the ordinary one-token row. A - /// green target is ONE token; a row that says more says something this - /// repository does not read, and it is named against the README that wrote it - /// rather than truncated into a claim it did not make. - /// - /// The rule this now stands on is the class's, not the seat branch's. It is - /// the same rule the route row two tests below is refused by, which is the - /// whole point: stated per branch it held here and nowhere else. - /// - /// The last two are the same defect where the trailing token is itself - /// target-shaped, which is the spelling a reader that stopped at the first - /// `::` would never notice. - #[test] - fn a_seat_row_carrying_more_than_its_target_is_unreadable() { - let text = " green: laws.rs root::reading_is_not_gaining extra\n\ - \x20 green: laws.rs root::reading_is_not_gaining — and the note nobody read\n\ - \x20 green: laws.rs root::reading_is_not_gaining\troot::closure_bar_is_implementable\n\ - \x20 green: laws.rs root::reading_is_not_gaining root::closure_bar_is_implementable\n"; - let read = classify_green_rows(text); - assert_eq!(read.len(), 4, "{read:?}"); - assert!( - read.iter() - .all(|row| matches!(*row, GreenRow::Unreadable(_))), - "a trailing token was discarded and the row still seated: {read:?}" - ); - assert!( - read.first().is_some_and(|row| matches!( - *row, - GreenRow::Unreadable(ref value) if value == "laws.rs root::reading_is_not_gaining extra" - )), - "{read:?}" - ); - } - - /// The positive control for that narrowing: the target this repository - /// actually writes — one token, however the row is spaced — is still a seat. - /// - /// A reader that refused every account carrying whitespace would satisfy the - /// reversal above and would unseat all 183 seat rows in the tree. - #[test] - fn a_seat_row_stating_exactly_its_target_is_still_a_seat() { - let text = " green: laws.rs root::reading_is_not_gaining\n\ - \x20 green:laws.rs root::reading_is_not_gaining\n\ - \x20 green: laws.rs\troot::reading_is_not_gaining\n\ - \x20 green: laws.rs root::reading_is_not_gaining \n"; - let read = classify_green_rows(text); - assert_eq!( - read, - vec![ - seat("root", "reading_is_not_gaining"), - seat("root", "reading_is_not_gaining"), - seat("root", "reading_is_not_gaining"), - seat("root", "reading_is_not_gaining"), - ], - "{read:?}" - ); - } - - /// Planted reversal: a route row carrying a token AFTER its path. - /// - /// The same defect as the seat row above, one branch to the right, and it - /// survived the round that closed the seat branch because that round wrote - /// its rule inside the seat reader. The route branch went on reading the - /// FIRST token and discarding the account: a row spelled - /// `testpak/tests/stamp_row_ceiling.rs missing-control.rs` resolved the real - /// seat, satisfied the phantom-route leg, and qualified while the second path - /// it stated was never looked for — which is precisely the failure the route - /// leg exists to refuse, arriving through the reader that feeds it. - /// - /// The last row is the spelling that makes the silence worst: the trailing - /// token is itself a real, executable seat, so the row states two positive - /// controls and exactly one of them is ever examined. - #[test] - fn a_route_row_carrying_more_than_its_path_is_unreadable() { - let text = " green: testpak/tests/stamp_row_ceiling.rs missing-control.rs\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs — and the note nobody read\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs\tmissing-control.rs\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs testpak/tests/stamp_row_ceiling.rs\n"; - let read = classify_green_rows(text); - assert_eq!(read.len(), 4, "{read:?}"); - assert!( - read.iter() - .all(|row| matches!(*row, GreenRow::Unreadable(_))), - "a token after the path was discarded and the row still routed: {read:?}" - ); - assert!( - read.first().is_some_and(|row| matches!( - *row, - GreenRow::Unreadable(ref value) - if value == "testpak/tests/stamp_row_ceiling.rs missing-control.rs" - )), - "{read:?}" - ); - } - - /// The positive control for that narrowing: the one route row this - /// repository actually writes, in every spacing the reader admits, is still - /// a route carrying its exact path. - /// - /// A reader that refused a route whose value carried any whitespace at all - /// would satisfy the reversal above and would unroute the only green route in - /// the tree — and an unrouted row is an unreadable row, so the whole check - /// would fail loudly rather than silently. Which is why the control names the - /// real path rather than a fixture one. - #[test] - fn a_route_row_stating_exactly_its_path_is_still_a_route() { - let text = " green: testpak/tests/stamp_row_ceiling.rs\n\ - \x20 green:testpak/tests/stamp_row_ceiling.rs\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs \n\ - \x20 green:\ttestpak/tests/stamp_row_ceiling.rs\n"; - let read = classify_green_rows(text); - assert_eq!( - read, - vec![ - GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")), - GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")), - GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")), - GreenRow::Route(String::from("testpak/tests/stamp_row_ceiling.rs")), - ], - "{read:?}" - ); - } - - /// Planted reversal: a target that is not exactly `module::law`, in all four - /// of its malformed spellings. - /// - /// Split on the FIRST `::` and asked nothing further, the first three were - /// seats. `root::reading_is_not_gaining::extra` seated the module `root` with - /// a law named `reading_is_not_gaining::extra`; `root::` seated a law whose - /// name is empty; `::reading_is_not_gaining` seated a law under a module - /// whose name is empty — and the empty module name is one `laws.rs` can - /// actually produce, because that file is read by tracking the module last - /// opened at the crate root and it starts as no module at all. A `#[test]` - /// written above the first `mod` is declared under exactly that name, and - /// this row would then resolve to it and qualify. - /// - /// Today none of the three resolves, so each was refused downstream by the - /// leg reporting a claim on a law `laws.rs` does not have: the right verdict - /// with the wrong subject, sending the author to `laws.rs` when the repair is - /// in the row. The fourth, a bare word with no separator at all, was already - /// read here. All four are now one answer. - #[test] - fn a_seat_target_that_is_not_exactly_module_law_is_unreadable() { - let text = " green: laws.rs root::reading_is_not_gaining::extra\n\ - \x20 green: laws.rs ::reading_is_not_gaining\n\ - \x20 green: laws.rs root::\n\ - \x20 green: laws.rs root\n"; - let read = classify_green_rows(text); - assert_eq!(read.len(), 4, "{read:?}"); - assert!( - read.iter() - .all(|row| matches!(*row, GreenRow::Unreadable(_))), - "a malformed target was seated: {read:?}" - ); - assert!( - read.first().is_some_and(|row| matches!( - *row, - GreenRow::Unreadable(ref value) - if value == "laws.rs root::reading_is_not_gaining::extra" - )), - "{read:?}" - ); - } - - /// The disposition account is PROSE, and it is held to no token ceiling. - /// - /// Stated as its own test so the asymmetry is a decision with a control on it - /// rather than the case a pass over the green class forgot. A disposition - /// accounts for why no file holds a positive control, and that account is a - /// sentence a person reads — it joins nothing, so there is no key for a - /// second token to make ambiguous. Every such row in this tree runs eight to - /// twelve words, and some run on across a wrapped line; a pass that carried - /// the seat and route ceiling across to them would unread all eleven at once. - /// - /// The reversal that matters here is the opposite one, and it is the test - /// below: prose having no CEILING is not prose having no FORM. The opener and - /// a word after it are still required. - #[test] - fn a_disposition_account_is_prose_and_carries_no_token_ceiling() { - let text = " green: none — no family payload can carry a spelling, skeleton, or scalar\n\ - \x20 green: owed — the P1–P10 campaigns land with testpak (the heartbeat's\n\ - \x20 green: structural (raw-pointer phantom makes the handle !Send and !Sync)\n\ - \x20 green: none — one\n"; - let read = classify_green_rows(text); - assert_eq!( - read, - vec![ - GreenRow::Disposition, - GreenRow::Disposition, - GreenRow::Disposition, - GreenRow::Disposition, - ], - "{read:?}" - ); - } - - /// The red rows are a DIFFERENT grammar, and this is the test that says so. - /// - /// A red row names its reversal and then speaks prose about it, which is how - /// this repository's tooling ledgers are written and how several rows in - /// `macros/macroc/README.md` are written today. The green side's target is now - /// exactly one token, and a pass that carried that rule across to the red side - /// would silently unresolve every one of those rows and shrink a published - /// denominator. Written here so the asymmetry is a decision with a control on - /// it rather than an inconsistency somebody tidies up. - #[test] - fn a_red_row_names_its_reversal_and_then_speaks_prose() { - let text = " tooling-red: testpak/tests/failed_seat_refusals.rs — the plane restores each\n\ - \x20 red: owed-to-testpak — cloning a Budget must not compile\n"; - assert_eq!( - tooling_red_rows(text), - vec![String::from( - "testpak/tests/failed_seat_refusals.rs — the plane restores each" - )] - ); - assert_eq!( - red_twin_rows(text), - vec![String::from( - "owed-to-testpak — cloning a Budget must not compile" - )] - ); - } - - /// Planted reversal: five green rows no lawful spelling reads. Under a - /// reader that FILTERED to the path-shaped rows, every one of them was - /// dropped — and a dropped row is an obligation that qualifies while the - /// positive control it names is never looked for. - #[test] - fn a_green_row_no_spelling_reads_is_named_not_dropped() { - let text = " green: testpak/tests/stamp_row_ceiling.r\n\ - \x20 green: testpak/tests/stamp_row_ceiling.md\n\ - \x20 green: structural\n\ - \x20 green:\n\ - \x20 green: laws.rs\n"; - assert_eq!( - classify_green_rows(text), - vec![ - GreenRow::Unreadable(String::from("testpak/tests/stamp_row_ceiling.r")), - GreenRow::Unreadable(String::from("testpak/tests/stamp_row_ceiling.md")), - GreenRow::Unreadable(String::from("structural")), - GreenRow::Unreadable(String::new()), - GreenRow::Unreadable(String::from("laws.rs")), - ] - ); - } - - /// One README block declaring three obligation records, the third of which - /// is the only one carrying a route. - const THREE_RECORDS: &str = "```yaml\n\ - home: bounds\n\ - obligations:\n\ - \x20 - id: bounds.classes-are-closed-and-seven\n\ - \x20 challenge_kind: compile-law\n\ - \x20 green: laws.rs bounds::classes_are_closed_and_seven\n\ - \x20 red: owed-to-testpak\n\ - \x20 - id: bounds.budget-is-affine\n\ - \x20 challenge_kind: compile-refusal\n\ - \x20 green: laws.rs bounds::budget_is_affine\n\ - \x20 red: owed-to-testpak — cloning a Budget must not compile\n\ - \x20 - id: bounds.a-stamped-roster\n\ - \x20 challenge_kind: compile-refusal\n\ - \x20 green: testpak/tests/stamp_row_ceiling.rs\n\ - \x20 red: testpak/tests/compile-fail/a-roster.rs\n\ - ```\n"; - - /// The positive control for the grouping: every row lands in the record - /// whose own block wrote it, read by the readers that already read rows. - /// - /// The third record is the load-bearing one — its green row is a ROUTE, and - /// the route now has to be joined to the obligation that named it, which is - /// impossible while a route is a loose row nobody's record owns. - #[test] - fn every_row_lands_in_the_record_that_wrote_it() { - let records = obligation_records(THREE_RECORDS); - assert_eq!(records.len(), 3, "{:?}", records.len()); - assert!( - records - .iter() - .all(|record| record.green.len() == 1 && record.red.len() == 1), - "a record lost or gained a row" - ); - let ids: Vec<&str> = records.iter().map(|record| record.id.as_str()).collect(); - assert_eq!( - ids, - vec![ - "bounds.classes-are-closed-and-seven", - "bounds.budget-is-affine", - "bounds.a-stamped-roster", - ] - ); - assert!( - records.last().is_some_and(|record| record.green.first() - == Some(&GreenRow::Route(String::from( - "testpak/tests/stamp_row_ceiling.rs" - )))), - "the route did not land in the record that named it" - ); - assert!( - records - .first() - .is_some_and(|record| record.red == vec![String::from("owed-to-testpak")]), - "the red row did not land in the record that declared it" - ); - } - - /// Planted reversal: an obligation whose `green:` row was deleted, and one - /// whose `red:` row was deleted. - /// - /// Read as loose rows, neither deletion is visible at all: the whole-file - /// scans simply return one row fewer, and no reader in this repository has - /// an opinion about a row that is not there. Read as records, the record - /// that lost a row is the record that carries none, and the emptiness is - /// something a reader downstream can name. - #[test] - fn a_record_that_lost_a_row_carries_none() { - let text = "```yaml\n\ - obligations:\n\ - \x20 - id: bounds.no-route-at-all\n\ - \x20 challenge_kind: compile-law\n\ - \x20 red: owed-to-testpak\n\ - \x20 - id: bounds.no-reversal-at-all\n\ - \x20 challenge_kind: compile-law\n\ - \x20 green: laws.rs bounds::budget_is_affine\n\ - ```\n"; - let records = obligation_records(text); - assert_eq!(records.len(), 2, "{:?}", records.len()); - assert!( - records - .first() - .is_some_and(|record| record.green.is_empty() && record.red.len() == 1), - "the record that lost its green row is not the record carrying none" - ); - assert!( - records - .last() - .is_some_and(|record| record.green.len() == 1 && record.red.is_empty()), - "the record that lost its red row is not the record carrying none" - ); - } - - /// Planted reversal: rows written where no record owns them — one above - /// every opener, and one standing back at the opener's own indentation. - /// - /// The grouping's own failure mode, and it is why the join counts what it - /// grouped against what the file wrote. A row no record carries is a row the - /// record reading never joins, so the grouping must not be able to lose one - /// quietly: here the whole-file readers see three green rows and the records - /// carry one, and that difference is the whole of what downstream needs. - #[test] - fn a_row_no_record_owns_is_carried_by_no_record() { - let text = "green: laws.rs bounds::a_row_above_every_record\n\ - red: owed-to-testpak\n\ - \x20 - id: bounds.the-one-real-record\n\ - \x20 green: laws.rs bounds::budget_is_affine\n\ - \x20 red: owed-to-testpak\n\ - \x20 green: laws.rs bounds::a_row_at_the_openers_own_depth\n"; - let records = obligation_records(text); - assert_eq!(records.len(), 1, "{:?}", records.len()); - assert!( - records - .first() - .is_some_and(|record| record.green.len() == 1 && record.red.len() == 1), - "a stray row was carried by a record that did not write it" - ); - assert_eq!(classify_green_rows(text).len(), 3); - assert_eq!(red_twin_rows(text).len(), 2); - } - - /// Planted reversal: a disposition that states the absence and withholds - /// the account of it, in all three of its words. - /// - /// The word alone is the half of the form that says nothing. `none` with no - /// reason is indistinguishable from a row somebody gave up on, and the - /// separator is what this repository's rows are actually written with. - #[test] - fn a_disposition_without_its_account_is_unreadable() { - let text = " green: none\n\ - \x20 green: owed\n\ - \x20 green: none - a hyphen is not the declared separator\n\ - \x20 green: structural ()\n\ - \x20 green: owed —\n"; - let read = classify_green_rows(text); - assert!( - read.iter() - .all(|row| matches!(*row, GreenRow::Unreadable(_))), - "{read:?}" - ); - assert_eq!(read.len(), 5, "{read:?}"); - } -} diff --git a/xtask/src/repository/rust.rs b/xtask/src/repository/rust.rs new file mode 100644 index 0000000..9bd434d --- /dev/null +++ b/xtask/src/repository/rust.rs @@ -0,0 +1,209 @@ +//! Rust syntax, read once, by the decoder that owns it. +//! +//! Every `.rs` file in the tree is parsed one time and the tree is carried. Four +//! laws used to parse their own populations out of their own walks — three of +//! them over the SAME files — so three readers could disagree about what a +//! source declares and nothing would have said so. +//! +//! # What a parse establishes here, and what it does not +//! +//! `syn` answers questions about SYNTAX and nothing else: which item kind is +//! declared, which visibility TOKEN is written on it, which attributes are +//! written on it, which fields it declares, what an implementation's written +//! form is. +//! +//! It does not answer resolved public reachability, what an alias points at, +//! which traits are reachable, which `cfg` a build will enable, what a macro +//! expands to, or who semantically owns a type. A law standing on this reader +//! states its claim in the reader's own terms — what a SOURCE declares — and +//! names the gap where a stronger claim would need the compiler rather than a +//! parse. + +use std::collections::BTreeMap; + +use crate::repository::snapshot::CanonicalFileMap; +use crate::repository::types::{AbsenceReason, CanonicalPath, Read, ReadFailure}; + +/// Every Rust source in the tree, parsed once. +pub(crate) struct RustSyntaxSnapshot { + /// Keyed by canonical path, so no law spells a source twice. + sources: BTreeMap>, +} + +impl RustSyntaxSnapshot { + /// Parses every `.rs` file the file map carries. + /// + /// A source that does not parse is carried as [`Read::Unreadable`] rather + /// than dropped. Several fixtures under `testpak/tests/` are written not to + /// compile on purpose, and whether such a file declares anything is UNKNOWN + /// rather than false — a hole reported as nothing is the silence this whole + /// model exists to end. + pub(crate) fn read(files: &CanonicalFileMap) -> Self { + let mut sources = BTreeMap::new(); + for (path, fact) in files.iter() { + if !path.extension_is("rs") { + continue; + } + let parsed = match *fact.text() { + Read::Known(ref text) => match syn::parse_file(text) { + Ok(file) => Read::Known(file), + Err(error) => { + Read::Unreadable(ReadFailure::new(path.as_str(), &error.to_string())) + } + }, + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + }; + sources.insert(path.clone(), parsed); + } + Self { sources } + } + + /// One parsed source, or the declared absence of the file carrying it. + pub(crate) fn source(&self, path: &CanonicalPath) -> Read<&syn::File> { + match self.sources.get(path) { + Some(Read::Known(parsed)) => Read::Known(parsed), + Some(Read::DeclaredAbsent(reason)) => Read::DeclaredAbsent(*reason), + Some(Read::Unreadable(failure)) => Read::Unreadable(failure.clone()), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchPath), + } + } + + /// Every source under one directory, parsed or not, in canonical path + /// order. + pub(crate) fn under( + &self, + directory: &str, + ) -> impl Iterator)> { + let inside = format!("{directory}/"); + self.sources + .iter() + .filter(move |(path, _)| path.as_str().starts_with(&inside)) + } + + /// Every source under the named directories, parsed, in the order the + /// directories are named. + /// + /// A source that did not parse REFUSES the whole reading rather than + /// leaving a population one file short. A law derived from a population is + /// a law about that population, and a population missing a member nobody + /// mentioned is a denominator that shrank in silence. + pub(crate) fn parsed_under( + &self, + directories: &[&str], + ) -> Result, String> { + let mut parsed = Vec::new(); + for directory in directories { + for (path, source) in self.under(directory) { + let file = source.required(path.as_str())?; + parsed.push((path, file)); + } + } + Ok(parsed) + } + + /// Every function item one source declares, at any inline module depth, or + /// the declared absence of the source that would carry them. + /// + /// The reading a law asks when its subject is "which functions does this + /// file declare, and what is written on each" — where the item stands, what + /// it is called, and its COMPLETE attribute set. Which of those functions + /// mean something is the asking law's question and is never decided here. + pub(crate) fn functions_in(&self, path: &CanonicalPath) -> Read>> { + self.source(path).map(declared_functions) + } +} + +/// One function item a source declares: where it stands, what it is called, and +/// every attribute written on it. +/// +/// The attribute set is carried WHOLE and unfiltered, because a reader that kept +/// only the attributes it recognized would answer "no such attribute" about one +/// it had never been told to look for. What an attribute means is the asking +/// law's question; that it is written is this reading's answer. +pub(crate) struct DeclaredFunction<'source> { + /// The inline modules the function is declared inside, spelled `outer::inner` + /// and BUILT on the way down. Empty where the function stands at the file's + /// own level, which is a fact about the source rather than a missing value. + module: String, + /// The name the item is declared under. + name: String, + /// Every attribute written on the item, in the order written. + attributes: &'source [syn::Attribute], +} + +impl<'source> DeclaredFunction<'source> { + /// The module path the function is declared inside. + pub(crate) fn module(&self) -> &str { + &self.module + } + + /// The name the item is declared under. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Every attribute written on the item. + pub(crate) const fn attributes(&self) -> &'source [syn::Attribute] { + self.attributes + } +} + +/// Every function item one parsed source declares, in declaration order. +/// +/// # Why the module path is built on the way down +/// +/// The same reason the file walker builds a canonical path on its way down: a +/// path recovered afterwards is a second derivation of a fact the walk already +/// had, and it needs a fallback for the case it cannot happen in. Each level +/// appends the module it just entered to the spelling it was handed. +/// +/// # What this reads, and what it cannot +/// +/// A `mod name;` reaching a SEPARATE file is not followed — this reading is +/// about one source. A function declared inside another function's BODY is not +/// an item of any module and is not here either. Both directions fail closed for +/// every caller in this crate: a function this reading does not carry is a +/// function no law can claim, and the law that would have claimed it is refused +/// by name rather than qualifying quietly. +/// +/// Nothing here resolves anything. No attribute is interpreted, no path is +/// resolved, no macro is expanded, no condition is evaluated. Two readers were +/// deleted from this crate for reaching past syntax into semantics, and this one +/// answers exactly the three questions a parse can answer about an item. +pub(crate) fn declared_functions(file: &syn::File) -> Vec> { + let mut declared = Vec::new(); + read_items(&file.items, "", &mut declared); + declared +} + +/// Reads one scope's items into the list, entering every inline module. +/// +/// Written as `if let` rather than as a `match` because `syn::Item` is +/// non-exhaustive: a match would need a wildcard arm, and a wildcard over a +/// foreign enum is the reading that stops being right the day the enum grows. +fn read_items<'source>( + items: &'source [syn::Item], + inside: &str, + into: &mut Vec>, +) { + for item in items { + if let syn::Item::Fn(declared) = item { + into.push(DeclaredFunction { + module: String::from(inside), + name: declared.sig.ident.to_string(), + attributes: &declared.attrs, + }); + } else if let syn::Item::Mod(module) = item + && let Some((_, inner)) = module.content.as_ref() + { + let named = module.ident.to_string(); + let deeper = if inside.is_empty() { + named + } else { + format!("{inside}::{named}") + }; + read_items(inner, &deeper, into); + } + } +} diff --git a/xtask/src/repository/snapshot.rs b/xtask/src/repository/snapshot.rs new file mode 100644 index 0000000..8da28f2 --- /dev/null +++ b/xtask/src/repository/snapshot.rs @@ -0,0 +1,935 @@ +//! One reading of the repository, built once, consumed by every law. +//! +//! This is the only module in this crate that touches the filesystem or starts a +//! process. Everything downstream is a pure function over what this established. +//! +//! # Why one reading rather than many +//! +//! Every law used to walk the tree for itself. Three of them parsed the same +//! Rust files three times; two of them read the same manifests through two +//! different readers; one of them decided which fenced block a document meant by +//! counting fences. Two readers over one population do not merely cost twice — +//! they can DISAGREE, and the disagreement is invisible, because each one +//! reports about the population it found. That is how a row seated by one reader +//! and claimed by neither qualified an obligation naming a law nobody wrote. +//! +//! One reading cannot disagree with itself. +//! +//! # Why nothing here has a fallback +//! +//! A reading that failed used to come back as a value that passes: an empty +//! string for a manifest, an empty vector for a tree, `"."` for a root nobody +//! resolved. Each of those answers the question the reader never got to ask, and +//! answers it in the direction that reports clean about bytes nobody opened. +//! Every fact here is a [`Read`], so a law either handles the absence or is +//! refused by [`Read::required`]; there is no method in this crate that turns an +//! unread fact into a value. + +use std::collections::BTreeMap; +use std::error::Error; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::repository::cargo::CargoSnapshot; +use crate::repository::markdown::MarkdownSnapshot; +use crate::repository::rust::RustSyntaxSnapshot; +use crate::repository::types::{AbsenceReason, CanonicalPath, LinkState, Read, ReadFailure}; + +/// Git's own storage, AT THE REPOSITORY ROOT. +/// +/// A directory in an ordinary clone and a FILE in a git worktree, where it +/// carries one line naming where the real storage lives. Both are git's, and +/// both are excluded here — otherwise one committed tree yields two different +/// populations depending on how the checkout was made, and every agent on this +/// campaign works in a worktree, so that is the live case rather than the exotic +/// one. It was live: the worktree's `.git` FILE was in the map, because the +/// exclusion asked whether an entry was a DIRECTORY before it asked what it was +/// called. +const GIT_STORAGE: &str = ".git"; + +/// The build's output directory, AT THE REPOSITORY ROOT. +/// +/// Cargo's output for this workspace, which is not repository material and is +/// large enough that walking it would make every run pay for bytes no law is +/// about. Only the root one: `target` is an ordinary word, and excluding it by +/// BASENAME at every depth silently deleted `src//target/`, +/// `docs/target/`, and `testpak/target/` from a population no law would have +/// reported missing. A file called `target` is not a build directory and is +/// read like any other file. +const BUILD_OUTPUT: &str = "target"; + +/// The metaprogramming subsystem's directory. +/// +/// Four laws name it, so the name answers to no single law and stands here +/// beside the reading they all consume. +pub(crate) const TOOLING_DIRECTORY: &str = "macros"; + +/// The directory the judge lives in, standing here for the same reason. +pub(crate) const JUDGE_DIRECTORY: &str = "testpak"; + +/// The machine's own source directory. +pub(crate) const MACHINE_DIRECTORY: &str = "src"; + +/// One reading of the repository. +/// +/// Built once, in `main`, and handed to every law. A law is given this and never +/// a path, which is what makes "no law walks the tree for itself" a fact of the +/// types rather than a convention somebody keeps. +pub(crate) struct RepositorySnapshot { + /// Every file in the tree, read once, with its bytes and its text. + files: CanonicalFileMap, + /// What Cargo's two authorities established. + cargo: CargoSnapshot, + /// Every Rust source, parsed once. + rust: RustSyntaxSnapshot, + /// Every Markdown document, parsed once. + markdown: MarkdownSnapshot, + /// What the bytes read are bound to, established AROUND the read rather + /// than asked afterwards. + binding: CommitBinding, +} + +impl RepositorySnapshot { + /// Reads the repository at one root. + /// + /// The order is the dependency order of the readings, and the git readings + /// BRACKET the walk rather than following it. The walk used to happen first + /// and git was asked afterwards, so a run printed a commit-bound sentence + /// about bytes it had never compared to that commit: on a dirty tree the + /// sentence named a commit whose content was not what had been read, and a + /// commit that moved mid-walk left the reading a mixture of two trees with + /// nothing saying so. Now git is asked before the walk and again after it, + /// the checkout is asked what differs, and [`CommitBinding::establish`] + /// either names the committed tree these bytes ARE or states what stops + /// them from being one. A commit that moved between the two readings + /// refuses the whole reading, because those bytes are about no single tree. + /// + /// The decoders come after the binding on purpose. `cargo metadata` starts + /// a process that writes into the build directory, and a reading that asked + /// what differs AFTER running it would be asking about a checkout its own + /// reading had touched. + pub(crate) fn read(root: &Path) -> Result { + let before = committed_tree(root); + let files = CanonicalFileMap::read(root)?; + let after = committed_tree(root); + let differences = working_tree_differences(root); + let binding = CommitBinding::establish(&before, &after, &differences)?; + let cargo = CargoSnapshot::read(root, &files); + let rust = RustSyntaxSnapshot::read(&files); + let markdown = MarkdownSnapshot::read(&files); + Ok(Self { + files, + cargo, + rust, + markdown, + binding, + }) + } + + /// Every file in the tree. + pub(crate) const fn files(&self) -> &CanonicalFileMap { + &self.files + } + + /// What Cargo's authorities established. + pub(crate) const fn cargo(&self) -> &CargoSnapshot { + &self.cargo + } + + /// Every Rust source, parsed. + pub(crate) const fn rust(&self) -> &RustSyntaxSnapshot { + &self.rust + } + + /// Every Markdown document, parsed. + pub(crate) const fn markdown(&self) -> &MarkdownSnapshot { + &self.markdown + } + + /// What the bytes this reading carries are bound to. + pub(crate) const fn binding(&self) -> &CommitBinding { + &self.binding + } +} + +/// Every file in the tree, keyed by the one spelling this repository uses. +pub(crate) struct CanonicalFileMap { + /// Ordered by canonical path, so every traversal — and so every diagnostic + /// — is the same on every machine and every run. + entries: BTreeMap, +} + +impl CanonicalFileMap { + /// Reads every file under one root, entering everything except + /// [`GIT_STORAGE`] and [`BUILD_OUTPUT`] AT THAT ROOT. + /// + /// A directory that cannot be listed refuses the whole reading. What such a + /// directory contains is unknown, and a snapshot built around an unknown is + /// a snapshot every law downstream would report clean about. + fn read(root: &Path) -> Result { + let mut entries = BTreeMap::new(); + read_directory(root, "", &mut entries)?; + Ok(Self { entries }) + } + + /// Every file, in canonical path order. + pub(crate) fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + /// One file's facts, or nothing where the tree carries no such path. + pub(crate) fn get(&self, path: &str) -> Option<&FileFact> { + self.entries.get(&CanonicalPath::spelled(path)) + } + + /// One file's text. + pub(crate) fn text(&self, path: &str) -> Read<&str> { + match self.entries.get(&CanonicalPath::spelled(path)) { + Some(fact) => match *fact.text() { + Read::Known(ref text) => Read::Known(text.as_str()), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + }, + None => Read::DeclaredAbsent(AbsenceReason::NoSuchPath), + } + } + + /// One file's bytes. + pub(crate) fn bytes(&self, path: &str) -> Read<&[u8]> { + match self.entries.get(&CanonicalPath::spelled(path)) { + Some(fact) => match *fact.bytes() { + Read::Known(ref bytes) => Read::Known(bytes.as_slice()), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + }, + None => Read::DeclaredAbsent(AbsenceReason::NoSuchPath), + } + } + + /// Every file under one directory, at any depth, in canonical path order. + pub(crate) fn under( + &self, + directory: &str, + ) -> impl Iterator { + let inside = format!("{directory}/"); + self.entries + .iter() + .filter(move |(path, _)| path.as_str().starts_with(&inside)) + } + + /// How many files the reading carries, for the line a run opens with. + pub(crate) fn count(&self) -> usize { + self.entries.len() + } +} + +/// What one reading of one file established. +pub(crate) struct FileFact { + /// Whether the entry is a symbolic link, or why that could not be + /// established. An entry the platform would not describe is UNKNOWN rather + /// than an ordinary file: a law about symlinks answering "ordinary" about an + /// entry nobody could stat is the fallback this model exists to delete. + link: Read, + /// The bytes, or why they were not read. + bytes: Read>, + /// The text those bytes decode to, or why they do not. + text: Read, +} + +impl FileFact { + /// Whether the entry is a symbolic link. + pub(crate) const fn link(&self) -> &Read { + &self.link + } + + /// The bytes, or why they were not read. + pub(crate) const fn bytes(&self) -> &Read> { + &self.bytes + } + + /// The text those bytes decode to, or why they do not. + pub(crate) const fn text(&self) -> &Read { + &self.text + } +} + +/// Reads one directory into the map, recursing in file-name order. +/// +/// The canonical spelling is BUILT on the way down rather than recovered on the +/// way back: each level appends the name it just read to the spelling it was +/// handed. Stripping a root off an absolute path afterwards would be a second +/// derivation of a fact this walk already has, and it would need a fallback for +/// the case it cannot happen in. +/// +/// # The two exclusions are ROOT-RELATIVE, and one of them ignores kind +/// +/// Being at the root is a fact this walk already has — `inside` is empty there +/// and nowhere else — so the two exclusions are asked exactly where they mean +/// something. Asked by BASENAME at every depth, as they were, `target` +/// disappeared a directory of repository material anywhere in the tree that +/// happened to carry the build's name, and no law would have reported the +/// absence, because a law is about the population it was handed. +/// +/// [`GIT_STORAGE`] is excluded whatever KIND the entry is, and that asymmetry is +/// the worktree repair: in a clone it is a directory and the old reading skipped +/// it, in a worktree it is a file and the old reading read it into the map. One +/// committed tree, two populations, decided by how somebody checked it out. +fn read_directory( + directory: &Path, + inside: &str, + into: &mut BTreeMap, +) -> Result<(), String> { + let listing = fs::read_dir(directory).map_err(|e| format!("{}: {e}", directory.display()))?; + let mut found = Vec::new(); + for entry in listing { + let entry = entry.map_err(|e| format!("{}: {e}", directory.display()))?; + let path = entry.path(); + let kind = entry + .file_type() + .map_err(|e| format!("{}: {e}", path.display()))?; + found.push((entry.file_name(), path, kind.is_dir())); + } + found.sort_by(|(left, _, _), (right, _, _)| left.cmp(right)); + let at_root = inside.is_empty(); + for (name, path, is_directory) in found { + let named = canonical_name(&name) + .map_err(|refusal| format!("{}: {refusal}", directory.display()))?; + if at_root && named == GIT_STORAGE { + continue; + } + if at_root && is_directory && named == BUILD_OUTPUT { + continue; + } + let spelled = if at_root { + named + } else { + format!("{inside}/{named}") + }; + if is_directory { + read_directory(&path, &spelled, into)?; + continue; + } + into.insert(CanonicalPath::spelled(&spelled), read_file(&path)); + } + Ok(()) +} + +/// One entry name as this repository spells names, or the refusal that says it +/// cannot be spelled at all. +/// +/// # Lossy conversion cannot be identity, so this refuses instead +/// +/// The walk used to build canonical paths with `to_string_lossy`, which maps +/// every unpaired surrogate and every ill-formed byte onto one replacement +/// character. Two entries whose names differ only where the conversion is lossy +/// therefore produce ONE [`CanonicalPath`], and the second insertion silently +/// overwrites the first: a file leaves the population with no error anywhere, +/// which is the exact silence this model exists to end. A rendering is a thing +/// to show a person; it is not an identity, and it was being used as the join +/// key of every law in this crate. +/// +/// Of the two lawful repairs — refuse the path, or key on raw platform bytes and +/// render separately — this repository refuses, and the reason is what the key +/// is FOR. A [`CanonicalPath`] is joined against text somebody wrote: an +/// obligation row naming a route, an allowlist entry, a README's declared +/// member, a band map. Those documents are UTF-8, so a path that cannot be +/// spelled in them is a path no row can name and no join can resolve; keying on +/// platform bytes would mint a second spelling for every path in the tree while +/// leaving the unnameable ones exactly as unjoinable as they are now. Refusing +/// costs a repository that carries such a name one clear refusal naming the +/// directory it is in, and this one carries none. +fn canonical_name(name: &OsStr) -> Result { + match name.to_str() { + Some(named) => Ok(String::from(named)), + None => Err(format!( + "the entry rendering as `{}` is not Unicode, so it has no canonical path spelling. \ + Every path identity in this crate is a join key against text — obligation rows, \ + allowlists, declared members — and a lossy rendering is not an identity: two such \ + names collapse onto one key and one of them leaves the population with nothing \ + saying so", + name.to_string_lossy() + )), + } +} + +/// One file's facts. +fn read_file(path: &Path) -> FileFact { + let spelled = path.display().to_string(); + let link = match fs::symlink_metadata(path) { + Ok(metadata) => Read::Known(if metadata.file_type().is_symlink() { + LinkState::Symlink + } else { + LinkState::RegularFile + }), + Err(error) => Read::Unreadable(ReadFailure::new(&spelled, &error.to_string())), + }; + let bytes = match fs::read(path) { + Ok(bytes) => Read::Known(bytes), + Err(error) => { + let failure = ReadFailure::new(&spelled, &error.to_string()); + return FileFact { + link, + bytes: Read::Unreadable(failure.clone()), + text: Read::Unreadable(failure), + }; + } + }; + let text = match bytes { + Read::Known(ref bytes) => match std::str::from_utf8(bytes) { + Ok(text) => Read::Known(text.to_owned()), + Err(error) => Read::Unreadable(ReadFailure::new(&spelled, &error.to_string())), + }, + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(ref failure) => Read::Unreadable(failure.clone()), + }; + FileFact { link, bytes, text } +} + +/// The committed state git names at one moment: a commit and the tree it names. +/// +/// The two are read as ONE fact rather than as two readings side by side. A +/// commit whose tree could not be read is not half a committed state — it is a +/// state nothing can be bound to, and carrying the halves separately left every +/// consumer to decide what a half meant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommittedTree { + /// The commit `HEAD` names. + commit: CommitId, + /// The tree that commit names. + tree: TreeId, +} + +impl fmt::Display for CommittedTree { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(out, "commit {} (tree {})", self.commit, self.tree) + } +} + +/// Asks git what `HEAD` names, or states that this root is not a checkout. +/// +/// Called TWICE around the walk. What it names is a committed state, which is +/// not the same fact as what was read off the disk — the files map is what was +/// read — and keeping those two apart is the whole of [`CommitBinding`]. +fn committed_tree(root: &Path) -> Read { + if !root.join(GIT_STORAGE).exists() { + return Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout); + } + let commit = match revision(root, "HEAD") { + Read::Known(named) => CommitId(named), + Read::DeclaredAbsent(reason) => return Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => return Read::Unreadable(failure), + }; + let tree = match revision(root, "HEAD^{tree}") { + Read::Known(named) => TreeId(named), + Read::DeclaredAbsent(reason) => return Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => return Read::Unreadable(failure), + }; + Read::Known(CommittedTree { commit, tree }) +} + +/// Every path git reports as differing from what is committed, one entry per +/// line of `git status --porcelain`, each carrying git's two status columns. +/// +/// Empty output is the entire clean condition — git prints one line per path +/// that differs from `HEAD` or is untracked, and nothing at all when there is +/// none. Nothing here interprets a status column; the lines are carried whole so +/// a refusal can name what differs rather than count it. +/// +/// This is a reading of a PROCESS's output rather than of repository text, which +/// is why it is a line reading and why that is not the defect class this crate +/// has been deleting: git's porcelain format is a line-per-path contract, and +/// the only fact taken from it here is how many lines there are. +fn working_tree_differences(root: &Path) -> Read> { + let output = Command::new("git") + .current_dir(root) + .args(["status", "--porcelain"]) + .stderr(Stdio::piped()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) => { + return Read::Unreadable(ReadFailure::new( + "git status --porcelain", + &error.to_string(), + )); + } + }; + if !output.status.success() { + return Read::Unreadable(ReadFailure::new( + "git status --porcelain", + String::from_utf8_lossy(&output.stderr).trim(), + )); + } + Read::Known( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(String::from) + .collect(), + ) +} + +/// What the bytes one reading carries are bound to. +/// +/// Two states, and the second one exists because the first was being CLAIMED +/// without being established. The reading walked the live filesystem, then asked +/// git what `HEAD` named, then printed `read N files at commit X` — a sentence +/// about a relationship between those bytes and that commit which nothing had +/// checked. On a dirty checkout the sentence was simply false, and `cargo xtask +/// check` alone never noticed: the worktree-clean stage that would have caught +/// it runs only under `qualify`, and only at the end. +/// +/// A verdict that cannot be attached to a tree is a verdict about nothing in +/// particular. So a reading either establishes the attachment or says out loud +/// that it has none — it never prints a commit it did not bind. +pub(crate) enum CommitBinding { + /// The bytes ARE this committed tree: git named the same commit and the + /// same tree on both sides of the walk, and the checkout carried nothing + /// differing from them. + Bound(CommittedTree), + /// The bytes are the working tree's, and this is what stops them from being + /// a committed tree. + Unbound(UnboundReason), +} + +impl CommitBinding { + /// The binding two git readings and one checkout reading establish, or the + /// refusal that says the reading is about no single tree. + /// + /// Pure over its three inputs, which is what lets the sentence a run opens + /// with be proven against fixture readings: a binding that could only be + /// tested by moving a commit under a running walk would never be tested. + /// + /// # What a bound reading establishes, and what it does not + /// + /// `git status --porcelain` reports every TRACKED path differing from `HEAD` + /// and every UNTRACKED path, and reports nothing for a path git IGNORES. The + /// walk enters every directory except the root's two, so an ignored + /// directory deeper in the tree would enter the file map without moving this + /// verdict. This repository ignores exactly one directory, and it is the + /// root build output the walk already refuses to enter, so the gap is empty + /// here and is stated rather than left to be discovered. It closes when the + /// walk's path set comes from git rather than from a filesystem listing — + /// which is a different reading, not a stricter version of this one. + fn establish( + before: &Read, + after: &Read, + differences: &Read>, + ) -> Result { + if before != after { + return Err(format!( + "the committed state moved while the repository was being read: git named {} \ + before the walk and {} after it, so the bytes this reading carries are a mixture \ + of two trees and no verdict over them is about either one", + named(before), + named(after) + )); + } + match *before { + Read::Known(ref committed) => match *differences { + Read::Known(ref entries) if entries.is_empty() => { + Ok(CommitBinding::Bound(committed.clone())) + } + Read::Known(ref entries) => Ok(CommitBinding::Unbound( + UnboundReason::WorkingTreeDiffers(entries.len()), + )), + Read::DeclaredAbsent(reason) => Ok(CommitBinding::Unbound( + UnboundReason::GitSaysNothing(reason), + )), + Read::Unreadable(ref failure) => Ok(CommitBinding::Unbound( + UnboundReason::GitRefused(failure.clone()), + )), + }, + Read::DeclaredAbsent(reason) => Ok(CommitBinding::Unbound( + UnboundReason::GitSaysNothing(reason), + )), + Read::Unreadable(ref failure) => Ok(CommitBinding::Unbound(UnboundReason::GitRefused( + failure.clone(), + ))), + } + } +} + +impl fmt::Display for CommitBinding { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + CommitBinding::Bound(ref committed) => write!( + out, + "and they are the committed tree at commit {} (tree {})", + committed.commit, committed.tree + ), + CommitBinding::Unbound(ref reason) => write!( + out, + "from the WORKING TREE; they are not a committed tree, so nothing this run reports \ + is bound to a commit: {reason}" + ), + } + } +} + +/// Why a reading's bytes are not a committed tree. +/// +/// Every variant is a statement somebody can act on: commit something, ask in a +/// checkout, or repair whatever refused. None of them is a shrug, and none of +/// them is an excuse to print a commit anyway. +pub(crate) enum UnboundReason { + /// The checkout carries paths differing from what is committed. + WorkingTreeDiffers(usize), + /// Git declared there is nothing to name here. + GitSaysNothing(AbsenceReason), + /// Git was asked and refused. + GitRefused(ReadFailure), +} + +impl fmt::Display for UnboundReason { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + UnboundReason::WorkingTreeDiffers(entries) => write!( + out, + "git reports {entries} path(s) in this checkout differing from what is committed" + ), + UnboundReason::GitSaysNothing(reason) => write!(out, "{reason}"), + UnboundReason::GitRefused(ref failure) => write!(out, "{failure}"), + } + } +} + +/// How one read fact is named in a refusal about it. +fn named(read: &Read) -> String { + match *read { + Read::Known(ref fact) => fact.to_string(), + Read::DeclaredAbsent(reason) => reason.to_string(), + Read::Unreadable(ref failure) => failure.to_string(), + } +} + +/// The object one revision names. +fn revision(root: &Path, spelling: &str) -> Read { + let output = Command::new("git") + .current_dir(root) + .args(["rev-parse", spelling]) + .stderr(Stdio::piped()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) => { + return Read::Unreadable(ReadFailure::new( + &format!("git rev-parse {spelling}"), + &error.to_string(), + )); + } + }; + if !output.status.success() { + return Read::Unreadable(ReadFailure::new( + &format!("git rev-parse {spelling}"), + String::from_utf8_lossy(&output.stderr).trim(), + )); + } + Read::Known(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +/// The commit a reading was taken at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommitId(String); + +impl fmt::Display for CommitId { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + out.write_str(&self.0) + } +} + +/// The tree a commit names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TreeId(String); + +impl fmt::Display for TreeId { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + out.write_str(&self.0) + } +} + +/// The workspace root: the parent of the xtask crate directory. +pub(crate) fn repo_root() -> Result> { + let manifest_directory = Path::new(env!("CARGO_MANIFEST_DIR")); + let parent = manifest_directory + .parent() + .ok_or("xtask crate directory has no parent")?; + Ok(parent.to_path_buf()) +} + +/// The cargo binary a spawned stage or reading is given. +/// +/// Cargo sets `CARGO` for every process it starts, so a nested invocation +/// reaches the exact binary that started this one — the pinned toolchain's +/// cargo, not whatever a machine's search path resolves today. The fallback +/// covers the case where the xtask binary is run directly, where no pin has +/// been resolved and the search path is all there is. +pub(crate) fn cargo_binary() -> OsString { + std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")) +} + +/// The reading of the real repository, built once for the whole test binary. +/// +/// Every law that judges the real tree is proven against THIS reading rather +/// than against one of its own, for the reason the model exists: two readings of +/// one tree are two trees a law can be judging. +#[cfg(test)] +pub(crate) fn repository_snapshot() -> Result<&'static RepositorySnapshot, String> { + use std::cell::RefCell; + // Once per test THREAD rather than once per binary: a parsed Rust tree is + // not `Sync`, because `proc-macro2` holds its tokens behind a reference + // count that is not atomic. One reading per thread is the sharing this model + // asks for — no two laws on one thread can be judging two trees — and it is + // the strongest sharing the parsed tree's own type admits. + thread_local! { + static READ: RefCell> = + const { RefCell::new(None) }; + } + READ.with(|held| { + if let Some(already) = *held.borrow() { + return Ok(already); + } + let root = repo_root().map_err(|error| error.to_string())?; + let built: &'static RepositorySnapshot = + Box::leak(Box::new(RepositorySnapshot::read(&root)?)); + *held.borrow_mut() = Some(built); + Ok(built) + }) +} + +/// Planted reversals for the reading itself. +#[cfg(test)] +mod tests { + use std::ffi::{OsStr, OsString}; + + use super::{ + BUILD_OUTPUT, CommitBinding, CommitId, CommittedTree, GIT_STORAGE, TreeId, canonical_name, + repository_snapshot, + }; + use crate::repository::types::{AbsenceReason, Read, ReadFailure}; + + /// One synthetic committed state. + fn committed(commit: &str, tree: &str) -> Read { + Read::Known(CommittedTree { + commit: CommitId(String::from(commit)), + tree: TreeId(String::from(tree)), + }) + } + + /// One synthetic checkout reading listing the paths that differ. + fn differing(paths: &[&str]) -> Read> { + Read::Known(paths.iter().map(|path| (*path).to_string()).collect()) + } + + /// The reading names what it read, and never names a commit it did not + /// bind. + /// + /// Read against the real tree, which is dirty exactly when somebody is + /// working in it — so this states the rule that holds in BOTH states rather + /// than a fact about one of them. A bound reading names a real committed + /// state; an unbound one accounts for itself and its sentence carries no + /// commit at all. + #[test] + fn the_reading_never_names_a_commit_it_did_not_bind() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sentence = snapshot.binding().to_string(); + match *snapshot.binding() { + CommitBinding::Bound(ref committed) => { + assert_eq!(committed.commit.to_string().len(), 40, "{committed}"); + assert_eq!(committed.tree.to_string().len(), 40, "{committed}"); + assert!( + sentence.contains(&committed.commit.to_string()), + "{sentence}" + ); + } + CommitBinding::Unbound(ref reason) => { + assert!( + sentence.contains("not a committed tree"), + "an unbound reading did not say so: {sentence}" + ); + assert!( + !sentence.contains("at commit"), + "an unbound reading named a commit anyway: {sentence}" + ); + assert!(!reason.to_string().is_empty(), "{sentence}"); + } + } + Ok(()) + } + + /// Planted reversal: the commit moving under the walk. + /// + /// THE failure the bracketing exists for. The reading used to walk the disk + /// and ask git afterwards, so a commit that moved mid-walk left the file map + /// carrying bytes from two trees while the sentence named whichever tree + /// happened to be current when the walk finished. Those bytes are about no + /// single tree, so the reading refuses rather than picking one. + #[test] + fn a_commit_that_moves_under_the_walk_refuses_the_reading() { + let found = CommitBinding::establish( + &committed("aaaa", "bbbb"), + &committed("cccc", "dddd"), + &differing(&[]), + ); + assert!( + found.is_err_and(|refusal| refusal + .contains("moved while the repository was being read") + && refusal.contains("aaaa") + && refusal.contains("cccc")), + "a reading spanning two trees was accepted" + ); + } + + /// Planted reversal: a dirty checkout is NOT a commit-bound reading. + /// + /// The sentence `cargo xtask check` opens with used to name a commit on any + /// tree at all, and only `qualify`'s closing stage — which runs last, and + /// only under `qualify` — would eventually notice. The bytes read on a dirty + /// tree are the working tree's, and saying so is the honest state. + #[test] + fn a_dirty_checkout_is_unbound_and_its_sentence_names_no_commit() -> Result<(), String> { + let state = committed( + "0123456789012345678901234567890123456789", + "9876543210987654321098765432109876543210", + ); + let found = CommitBinding::establish(&state, &state, &differing(&[" M src/lib.rs"]))?; + let sentence = found.to_string(); + assert!( + !sentence.contains("0123456789"), + "an unbound reading named the commit anyway: {sentence}" + ); + assert!(sentence.contains("1 path(s)"), "{sentence}"); + Ok(()) + } + + /// The positive control: a clean checkout at a settled commit IS bound, and + /// its sentence names the tree the bytes are. + #[test] + fn a_clean_checkout_at_one_commit_is_bound() -> Result<(), String> { + let state = committed( + "0123456789012345678901234567890123456789", + "9876543210987654321098765432109876543210", + ); + let found = CommitBinding::establish(&state, &state, &differing(&[]))?; + let sentence = found.to_string(); + assert!( + sentence.contains("0123456789012345678901234567890123456789"), + "{sentence}" + ); + assert!( + sentence.contains("9876543210987654321098765432109876543210"), + "{sentence}" + ); + Ok(()) + } + + /// Planted reversal: a root that is no checkout, and a git that refused. + /// + /// Both used to print `unknown (…)` beside the word `commit` and carry on, + /// which is a run claiming a commit-bound result while stating it has no + /// commit. Neither is bound now, and the sentence stops claiming one. + #[test] + fn an_unknown_commit_binds_nothing() -> Result<(), String> { + let absent: Read = Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout); + let sentence = CommitBinding::establish(&absent, &absent, &differing(&[]))?.to_string(); + assert!(sentence.contains("not a committed tree"), "{sentence}"); + assert!(sentence.contains("not a git checkout"), "{sentence}"); + + let refused: Read = + Read::Unreadable(ReadFailure::new("git rev-parse HEAD", "no such ref")); + let said = CommitBinding::establish(&refused, &refused, &differing(&[]))?.to_string(); + assert!(said.contains("not a committed tree"), "{said}"); + assert!(said.contains("no such ref"), "{said}"); + Ok(()) + } + + /// Planted reversal: a name that has no Unicode spelling refuses the + /// reading rather than collapsing onto a replacement character. + /// + /// `to_string_lossy` maps every ill-formed name onto the same replacement + /// character, so two entries differing only there produced ONE canonical + /// path and the second insertion overwrote the first — a file leaving the + /// population with no error anywhere. The ill-formed name is built in the + /// platform's own terms, because there is no portable spelling of one; both + /// arms assert the same refusal, so whichever platform a run happens on, one + /// of them executes. + #[test] + fn a_name_with_no_unicode_spelling_refuses_the_reading() { + #[cfg(windows)] + let ill_formed = { + use std::os::windows::ffi::OsStringExt; + // An unpaired high surrogate: a name Windows accepts and UTF-8 + // cannot spell. + OsString::from_wide(&[0x0073_u16, 0xD800_u16, 0x0074_u16]) + }; + #[cfg(unix)] + let ill_formed = { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(vec![0x73_u8, 0xFF_u8, 0x74_u8]) + }; + assert!( + canonical_name(&ill_formed).is_err_and(|refusal| refusal.contains("is not Unicode")), + "a name with no Unicode spelling was given a canonical path anyway" + ); + assert_eq!( + canonical_name(OsStr::new("README.md")), + Ok(String::from("README.md")) + ); + } + + /// The two unread entries are unread AT THE ROOT and nowhere else. + /// + /// Planted reversal for the basename rule, read off the real tree. Both + /// names are excluded where they mean what they say — git's storage and the + /// build's output, at the root — and neither is excluded as a WORD, so a + /// directory called `target` anywhere else in the tree is repository + /// material and is read like any other. + #[test] + fn the_root_exclusions_are_root_relative() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let unread: Vec<&str> = snapshot + .files() + .iter() + .map(|(path, _)| path.as_str()) + .filter(|path| { + path.split('/') + .next() + .is_some_and(|head| head == GIT_STORAGE || head == BUILD_OUTPUT) + }) + .collect(); + assert!(unread.is_empty(), "{unread:?}"); + assert!( + snapshot.files().get(GIT_STORAGE).is_none(), + "the worktree's `{GIT_STORAGE}` file is in the canonical file map, so this checkout \ + reads differently from a clone of the same commit" + ); + Ok(()) + } + + /// A file the tree does not carry is ABSENT, and absent is not empty. + /// + /// Planted reversal for every fallback this model deleted. The reading used + /// to answer a missing manifest with an empty string, and an empty manifest + /// declares no prohibited edge — a law reporting clean about bytes nobody + /// opened. + #[test] + fn a_path_the_tree_does_not_carry_is_absent_rather_than_empty() -> Result<(), String> { + let snapshot = repository_snapshot()?; + assert!(matches!( + snapshot.files().text("no/such/file.md"), + Read::DeclaredAbsent(_) + )); + assert!( + snapshot + .files() + .text("Cargo.toml") + .required("the root manifest")? + .contains("[workspace]"), + "the root manifest was not read" + ); + Ok(()) + } +} diff --git a/xtask/src/repository/types.rs b/xtask/src/repository/types.rs index 63f3215..43f57ea 100644 --- a/xtask/src/repository/types.rs +++ b/xtask/src/repository/types.rs @@ -1,15 +1,242 @@ //! The vocabulary the two families share. //! //! What crosses the line between reading the repository and judging it lives -//! here, and nothing else does: the shape of a law, how a declared module sits -//! on disk, and how one obligation row is spelled. Everything a single law needs -//! for itself is private to that law, because a name shared by one owner is a -//! name in the wrong place. +//! here, and nothing else does: what a reading established, how a path is +//! spelled once and for all, how a declared module sits on disk, and how one +//! obligation row is written. Everything a single law needs for itself is +//! private to that law, because a name shared by one owner is a name in the +//! wrong place. -use std::path::Path; +use std::fmt; + +use crate::repository::snapshot::RepositorySnapshot; /// One repository law: a name and the function that checks it. -pub(crate) type Check = (&'static str, fn(&Path) -> Result<(), String>); +/// +/// A law is handed the SNAPSHOT and never a path. That is the whole shape of +/// this model: the tree is read once, by one builder, and every law is a pure +/// function over what that reading established. A law that took a path could +/// walk the filesystem again, and two laws walking it separately are two laws +/// that can be judging different trees. +pub(crate) type Check = (&'static str, fn(&RepositorySnapshot) -> Result<(), String>); + +/// What one reading of one fact established. +/// +/// Three states, and the third is the one every fallback in this crate used to +/// spell as one of the first two. A file that could not be read came back as an +/// empty string; a directory that could not be listed came back as an empty +/// vector; a root that could not be resolved came back as `"."`. Each of those +/// answers a question the reader never got to ask, and each answers it in the +/// direction that PASSES: an empty manifest declares no prohibited edge, an +/// empty tree holds no offending file, and a law reported clean about bytes +/// nobody opened. +/// +/// Unknown is not false and is not an empty collection. A caller either handles +/// all three states or asks [`Read::required`] for the fact and is refused when +/// it is not there — there is no third road, because there is no method here +/// that turns an unread fact into a value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Read { + /// The fact, as the reading that owns it established it. + Known(T), + /// The fact is not there, and the reason is DECLARED rather than inferred. + DeclaredAbsent(AbsenceReason), + /// The reading failed. What it was reading and what the failure said are + /// both carried, because a caller reporting "unknown" must be able to say + /// unknown about WHAT. + Unreadable(ReadFailure), +} + +impl Read { + /// The fact where it is known, and nothing where it is not. + /// + /// The escape hatch is deliberately shaped as an `Option` rather than as a + /// value with a default: a caller that reaches for this has to write what it + /// does about the absence, in its own words, at the site where the absence + /// matters. + pub(crate) const fn known(&self) -> Option<&T> { + match *self { + Read::Known(ref fact) => Some(fact), + Read::DeclaredAbsent(_) | Read::Unreadable(_) => None, + } + } + + /// The fact, or a refusal naming what could not be established about it. + /// + /// THE road a law takes to a fact it needs. Absence and failure both become + /// refusals, in their own words, because a law standing on a fact nobody + /// established is a law reporting about bytes nobody opened. + pub(crate) fn required(&self, subject: &str) -> Result<&T, String> { + match *self { + Read::Known(ref fact) => Ok(fact), + Read::DeclaredAbsent(reason) => Err(format!("{subject} is not there: {reason}")), + Read::Unreadable(ref failure) => Err(format!("{subject} could not be read: {failure}")), + } + } + + /// The fact taken OUT of the reading, or a refusal naming what could not be + /// established about it. + /// + /// The twin of [`Read::required`], for the readings that are values rather + /// than fields: an accessor that answers a question — one file's text, one + /// key's string, one document's ledger — hands back a reading built for that + /// call, and a borrow of it would not outlive the call. Same three states, + /// same refusals, and no fallback in either. + pub(crate) fn taken(self, subject: &str) -> Result { + match self { + Read::Known(fact) => Ok(fact), + Read::DeclaredAbsent(reason) => Err(format!("{subject} is not there: {reason}")), + Read::Unreadable(failure) => Err(format!("{subject} could not be read: {failure}")), + } + } + + /// The same reading, with a known fact renamed into the type that names it. + /// + /// Deliberately the only combinator here. A reading may be renamed; it may + /// not be unwrapped, defaulted, or filtered into one of its other states, + /// because each of those is a fallback wearing a method name. + pub(crate) fn map(self, into: impl FnOnce(T) -> U) -> Read { + match self { + Read::Known(fact) => Read::Known(into(fact)), + Read::DeclaredAbsent(reason) => Read::DeclaredAbsent(reason), + Read::Unreadable(failure) => Read::Unreadable(failure), + } + } +} + +/// Why a fact is absent. +/// +/// Every variant is a statement somebody made about the tree, never a shrug. An +/// absence that cannot be spelled as one of these is not an absence — it is a +/// [`Read::Unreadable`], and it says so. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbsenceReason { + /// The snapshot's file map carries no such path. + NoSuchPath, + /// The root declares no `Cargo.toml`, so there is no workspace for cargo to + /// read and no reading to ask about. + NotAWorkspaceCheckout, + /// The root is not a git checkout, so no commit names what was read. + NotAGitCheckout, + /// The document declares no data block carrying the schema asked for. + NoBlockDeclaresThisSchema, + /// The document is there and states no such key. + NoSuchKey, +} + +impl fmt::Display for AbsenceReason { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + let said = match *self { + AbsenceReason::NoSuchPath => "no file in the repository sits at that path", + AbsenceReason::NotAWorkspaceCheckout => { + "the root declares no Cargo.toml, so cargo reports nothing here" + } + AbsenceReason::NotAGitCheckout => "the root is not a git checkout", + AbsenceReason::NoBlockDeclaresThisSchema => { + "no fenced data block in that document declares this schema" + } + AbsenceReason::NoSuchKey => "the document states no such key", + }; + out.write_str(said) + } +} + +/// What a reading that failed was reading, and what the failure said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReadFailure { + /// What was being read, as this repository spells it. + subject: String, + /// What the failure said, in the words of whatever refused. + said: String, +} + +impl ReadFailure { + /// One failure, carrying its subject and the words the refusal came in. + pub(crate) fn new(subject: &str, said: &str) -> Self { + Self { + subject: subject.to_owned(), + said: said.to_owned(), + } + } +} + +impl fmt::Display for ReadFailure { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(out, "{}: {}", self.subject, self.said) + } +} + +/// One path, spelled the one way this repository spells paths: relative to the +/// root, forward slashes, on every platform. +/// +/// A newtype rather than a `String` because the spelling is the join key. Every +/// resolution in this crate — a green route against a test seat, a red row +/// against a fixture, an allowlist entry against a scanned file — compares one +/// of these to another, and a comparison between a path spelled two ways is a +/// join that silently answers no. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct CanonicalPath(String); + +impl CanonicalPath { + /// The one construction road, taking the spelling as this repository writes + /// it: repository-relative, forward slashes, no leading `./`. + pub(crate) fn spelled(relative: &str) -> Self { + Self(relative.replace('\\', "/")) + } + + /// The path as text, for a message or a comparison against a row's value. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + + /// Whether the path sits inside a named directory, at any depth. The prefix + /// is matched with its trailing slash, so `src` never matches `src-notes/`. + pub(crate) fn is_under(&self, directory: &str) -> bool { + self.0.starts_with(&format!("{directory}/")) + } + + /// Whether the path sits DIRECTLY inside a named directory and no deeper. + pub(crate) fn sits_directly_in(&self, directory: &str) -> bool { + self.is_under(directory) + && self + .0 + .get(directory.len().saturating_add(1)..) + .is_some_and(|tail| !tail.contains('/')) + } + + /// Whether the path names a file with the given extension. + pub(crate) fn extension_is(&self, extension: &str) -> bool { + self.0 + .rsplit_once('.') + .is_some_and(|(_, found)| found == extension) + } + + /// The last segment of the path. + pub(crate) fn file_name(&self) -> &str { + self.0 + .rsplit_once('/') + .map_or(self.0.as_str(), |(_, name)| name) + } +} + +impl fmt::Display for CanonicalPath { + fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { + out.write_str(&self.0) + } +} + +/// Whether one file is a symbolic link. +/// +/// A named state rather than a boolean field, because `clippy.toml` sets +/// `max-struct-bools = 0` and because `true` says nothing about which way the +/// question was asked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LinkState { + /// An ordinary file, whose bytes are its content. + RegularFile, + /// A symbolic link, whose content is a path to somewhere else. + Symlink, +} /// How a declared module is laid out on disk. /// @@ -28,9 +255,9 @@ pub(crate) enum ModuleLayout { /// One obligation RECORD, and the rows its own block declared. /// /// The unit an obligation is written in, carried as the unit it is written in. -/// A record opens at `- id:` and states its fields beneath itself, and the rows -/// here are exactly the ones that block carried — not every row the file -/// happens to contain. +/// A record opens at a sequence item stating `id:` and carries the fields +/// written beneath that item, and the rows here are exactly the ones that item +/// carried — not every row the file happens to contain. /// /// It exists because the rows used to be gathered by two independent scans of /// the WHOLE file, with nothing binding a row to the record it belongs to. A @@ -40,15 +267,12 @@ pub(crate) enum ModuleLayout { /// anywhere. Neither is reachable through a value that cannot be built without /// the rows it owns: a record carries its own rows or it carries none, and /// carrying none is a fact the join can see. -/// -/// The rows keep their own readers and their own types. This is a grouping of -/// what those readers produced, never a second reading of the same lines. pub(crate) struct ObligationRecord { - /// The identity the record opened with, as `- id:` stated it. + /// The identity the record opened with, as its `id:` field stated it. pub(crate) id: String, - /// Every `green:` row this record's own block declared, classified. + /// Every `green:` row this record's own item declared, classified. pub(crate) green: Vec, - /// Every `red:` row this record's own block declared, whole. + /// Every `red:` row this record's own item declared, whole. pub(crate) red: Vec, } diff --git a/xtask/src/repository/walk.rs b/xtask/src/repository/walk.rs deleted file mode 100644 index b7ba213..0000000 --- a/xtask/src/repository/walk.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Walking the tree: where the repository root is, the order its files are -//! visited in, how a path is spelled back, and what one declared module -//! contributes. -//! -//! Every law that judges the tree rather than a text reads it through here, so -//! no two laws can be judging different trees. The named subsystem directories -//! sit here as well: three separate laws name them, so none of those laws owns -//! the name, and it belongs beside the walker they all reach for. - -use std::error::Error; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::repository::types::ModuleLayout; - -/// Directories never visited by repository-wide file checks. -const SKIP_DIRS: [&str; 2] = [".git", "target"]; - -/// The metaprogramming subsystem's directory. -/// -/// The topology law refuses a dependency path into it, and both scanning laws -/// walk it, so the name answers to no single law and stands here instead. -pub(crate) const TOOLING_DIRECTORY: &str = "macros"; - -/// The directory the judge lives in, standing here for the same reason: the -/// topology law, both scanning laws, and the reversal inventory all name it. -pub(crate) const JUDGE_DIRECTORY: &str = "testpak"; - -/// The workspace root: the parent of the xtask crate directory. -pub(crate) fn repo_root() -> Result> { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let parent = manifest_dir - .parent() - .ok_or("xtask crate directory has no parent")?; - Ok(parent.to_path_buf()) -} - -/// Visits every file under `dir`, skipping [`SKIP_DIRS`]. -/// -/// Entries are visited in file-name order rather than in the order the -/// filesystem happens to return them, so every check's traversal — and so every -/// diagnostic it emits — is the same on every machine and every run. -pub(crate) fn visit_files( - dir: &Path, - visit: &mut dyn FnMut(&Path) -> Result<(), String>, -) -> Result<(), String> { - let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let mut entries = Vec::new(); - for entry in read { - let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; - let path = entry.path(); - let is_dir = entry - .file_type() - .map_err(|e| format!("{}: {e}", path.display()))? - .is_dir(); - entries.push((entry.file_name(), path, is_dir)); - } - entries.sort_by(|(left, _, _), (right, _, _)| left.cmp(right)); - for (name, path, is_dir) in entries { - if is_dir { - if !SKIP_DIRS.contains(&name.to_string_lossy().as_ref()) { - visit_files(&path, visit)?; - } - } else { - visit(&path)?; - } - } - Ok(()) -} - -/// The repository-relative path, slash-separated on every platform. -pub(crate) fn relative_slash_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") -} - -/// The source text one declared module contributes, and the layout it is in. -/// -/// This is the stage that turns a declared NAME into the text an order is read -/// off. `name.rs` is its own text; `name/` is every `.rs` file under it joined -/// together, because a submodule reaching forward is its parent reaching -/// forward. The check and the law that judges the real tree both read a module -/// through here, so neither can be judging a different tree than the other. -pub(crate) fn module_source(src: &Path, name: &str) -> Result<(String, ModuleLayout), String> { - let flat = src.join(format!("{name}.rs")); - if flat.is_file() { - let text = fs::read_to_string(&flat).map_err(|e| format!("{}: {e}", flat.display()))?; - return Ok((text, ModuleLayout::Flat)); - } - let directory = src.join(name); - if !directory.is_dir() { - return Err(format!( - "{name} is declared and is neither {} nor {}/", - flat.display(), - directory.display() - )); - } - let mut collected = String::new(); - visit_files(&directory, &mut |path| { - if path.extension().is_some_and(|extension| extension == "rs") { - let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - collected.push_str(&text); - collected.push('\n'); - } - Ok(()) - })?; - Ok((collected, ModuleLayout::Directory)) -}