From 95967b097fb3203ab6a133b6ccd56dd45f35a980 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 00:05:57 -0400 Subject: [PATCH 1/9] Seat the runtime rung of the positivity ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NonEmptyBounded::admitted` promised an inhabitant while consuming `LimitWitness`, which admits a zero selection on purpose. Its const twin `admitted_const` has taken `PositiveLimit` since the witness split; the runtime road was the one hole left in that ladder, and a family whose evidence selected a zero magnitude had an inhabitant-promising road every call of which refuses. `PositiveLimitWitness` closes it. It contains the `LimitWitness` rather than restating its magnitude, so the selection and the number this witness reports have one owner, exactly as `PositiveLimit` contains `AdmittedLimit`. It establishes the family, a positive capacity, and the admitted runtime maximum, and it establishes nothing about whether that capacity is semantically appropriate: the owner profile and the evidence select that, no road can check it, and the type says so at its claim ceiling. The population that needed it, derived rather than taken on faith: eight limit families in the machine bound a seat that promises an inhabitant (`NonEmptyBounded` / `AdmittedPrefix`) and declare no compile-time magnitude. Seven are collection-shaped refusal bodies; the eighth is the shred denominator's participant roster, which is not a refusal body and is easy to miss when the population is read off the refusal families alone. Each said "a declared finite bound, evidence-selected" in a doc comment and said it nowhere a road could read. `EvidenceSelectedLimit` is where that sentence becomes a fact the compiler carries, and it is the bound on the mint, so a family that never made the declaration has no road to a runtime capacity. Three reversals, executed and committed. A capacity naming another family does not typecheck at a consumer naming its own. A family that never declared the ladder does not reach the mint. A zero capacity refuses — not at compile time, because a magnitude that does not exist until runtime has no value a `const` block could read; where that relation IS visible in the source it already sits in `PositiveLimit`'s `const` gate, which is the stronger seat and stays there. Drain candidates this exposes: - `root.positivity-is-the-stronger-witness` and the new `root.a-runtime-capacity-is-witnessed-positive` state one relation on two ladders. Neither is a restatement of the other today — one is settled by a `const` block and one by a checked road — but if `AdmittedPrefix` ever gains a runtime mint that makes both roads reachable for one family, the pair wants re-reading. - The doc-comment sentences on the eight families are now weaker restatements of their `EvidenceSelectedLimit` declarations. They were rewritten to cite the declaration rather than assert the fact; a later pass could drop the prose. Found and not repaired, deliberately: - Every `AdmittedPrefix` mint is bounded on `ConstLimit`, so the seven evidence-selected refusal bodies still cannot be built at all — the witness they would consume now exists and the coupled package has no road that takes it. Adding one would be a road with no caller in a home that has no producer; it is named in `src/13_declaration/README.md` instead of closed here. - The derived-population leg is OWED and is fenced out of this worktree: no `cargo xtask check` law derives the population of inhabitant-promising limit families from the sources and reads the capacity declaration off each one, so no `N witnessed / N declared` denominator prints. One side of that join drifts loudly meanwhile — the recorded diagnostic in `a-capacity-minted-for-an-undeclared-family.stderr` carries the compiler's own roster of the ladder, so a family joining or leaving it fails that fixture. That is a drift detector over one side, not a denominator over both, and the READMEs and the law say so rather than claiming the universal. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 47 ++++- src/00_refusal/types.rs | 19 ++ src/13_declaration/README.md | 22 +- src/13_declaration/types.rs | 21 +- src/14_semantic/types.rs | 6 +- src/15_execution/types.rs | 11 +- src/18_bvisor/types.rs | 5 +- src/22_security/types.rs | 7 +- src/laws.rs | 178 +++++++++++++++- src/types.rs | 193 +++++++++++++++++- ...apacity-minted-for-an-undeclared-family.rs | 59 ++++++ ...ity-minted-for-an-undeclared-family.stderr | 29 +++ .../a-capacity-witness-from-another-family.rs | 50 +++++ ...apacity-witness-from-another-family.stderr | 15 ++ 14 files changed, 630 insertions(+), 32 deletions(-) create mode 100644 testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs create mode 100644 testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.stderr create mode 100644 testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs create mode 100644 testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr diff --git a/README.md b/README.md index d2d1bc2..1650f62 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,35 @@ 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. + +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 is NOT claimed here: +"every limit family whose seat promises an inhabitant declares its ladder" is a +population question, and no repository join derives that population from the +sources. One side of it drifts loudly meanwhile — `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. That is a drift detector over one side, not a +denominator over both. + Each construction road states its own claim class, and the classes do not substitute for one another: @@ -153,7 +182,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 +308,22 @@ 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.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/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/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..adc73c0 100644 --- a/src/13_declaration/types.rs +++ b/src/13_declaration/types.rs @@ -691,12 +691,15 @@ 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 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 +817,13 @@ 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 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 @@ -940,11 +945,13 @@ 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 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 diff --git a/src/14_semantic/types.rs b/src/14_semantic/types.rs index 2aee0ca..a6a3c43 100644 --- a/src/14_semantic/types.rs +++ b/src/14_semantic/types.rs @@ -198,11 +198,13 @@ 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 crate::types::EvidenceSelectedLimit for SemanticFormIssueLimit {} /// Semantic Form construction. Completion posture rule: complete diagnosis /// is PERMITTED, NEVER REQUIRED, AND NEVER ASSUMED — a first-party producer diff --git a/src/15_execution/types.rs b/src/15_execution/types.rs index 2ec1a9d..ec45ad7 100644 --- a/src/15_execution/types.rs +++ b/src/15_execution/types.rs @@ -322,11 +322,13 @@ 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 crate::types::EvidenceSelectedLimit for ExecutionFormIssueLimit {} /// Execution Form construction. Posture addition over the Semantic Form /// family: the independent reference lowerer posts `EarlyStopped` at its @@ -688,10 +690,13 @@ 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 crate::types::EvidenceSelectedLimit for EffectBatchIssueLimit {} /// Effect-batch composition. Posture: `Complete` when every applicable check /// ran; `EarlyStopped` only when (a) an established issue makes remaining diff --git a/src/18_bvisor/types.rs b/src/18_bvisor/types.rs index 6bf7fb7..39e3d1b 100644 --- a/src/18_bvisor/types.rs +++ b/src/18_bvisor/types.rs @@ -363,10 +363,13 @@ 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 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 diff --git a/src/22_security/types.rs b/src/22_security/types.rs index b3b528a..dd9add9 100644 --- a/src/22_security/types.rs +++ b/src/22_security/types.rs @@ -271,10 +271,15 @@ 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 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/laws.rs b/src/laws.rs index b8b9be6..fe61fdc 100644 --- a/src/laws.rs +++ b/src/laws.rs @@ -337,6 +337,178 @@ 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 {} + 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 {} + impl EvidenceSelectedLimit for FirstDemo {} + struct SecondDemo; + impl Limit for SecondDemo {} + 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, in two parts. The declaration says the magnitude + /// arrives at runtime; it does NOT say the family declares no compile-time + /// magnitude, and a family stating both would be stating two authorities for + /// one capacity — a declaration defect no bound here can see. And it does + /// not say that every family in this crate whose seat promises an inhabitant + /// has made the declaration: that 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. + /// + /// 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 remains owed; no + /// `cargo xtask check` law derives it. + /// + /// 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 {} + 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-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 @@ -5771,7 +5943,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 +6030,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..7c151b4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -72,6 +72,40 @@ pub trait ConstLimit: Limit { 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. It also +/// does not claim the family declares no compile-time magnitude: a family +/// implementing both this and [`ConstLimit`] would be stating two authorities +/// for one capacity, and that is a declaration defect no bound here can see. +pub trait EvidenceSelectedLimit: Limit {} + /// The ceiling one PLANE admits its declared magnitudes under. /// /// # Root owns the algebra; a profile owns the number @@ -299,6 +333,20 @@ 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. #[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 +372,120 @@ 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`]. +#[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 +857,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 +890,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/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..bc519ad --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs @@ -0,0 +1,59 @@ +//! 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, Limit, LimitWitness, PositiveLimitWitness, +}; + +/// A family whose owner declared the magnitude evidence-selected. +struct DeclaredFamily; + +impl Limit for DeclaredFamily {} + +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 {} + +/// 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..a0fbd41 --- /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:54:5 + | +54 | 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:38:1 + | +38 | 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..dbebe77 --- /dev/null +++ b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs @@ -0,0 +1,50 @@ +//! 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, Limit, PositiveLimitWitness}; + +/// One family whose magnitude the owner's evidence selects. +struct FirstFamily; + +impl Limit for FirstFamily {} + +impl EvidenceSelectedLimit for FirstFamily {} + +/// A second family on the same ladder, so the declaration is not the difference. +struct SecondFamily; + +impl Limit for SecondFamily {} + +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..341321a --- /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:44:22 + | +44 | 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:34:4 + | +34 | fn admits_the_first(_capacity: &PositiveLimitWitness) {} + | ^^^^^^^^^^^^^^^^ --------------------------------------------- From ed72d8e3a6776be3351add4288a8d0cddf6d5725 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 00:19:23 -0400 Subject: [PATCH 2/9] Seat the second harness and the mutation alarm beside the bar, and classify the fallback population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT REMAINS UNPROVEN, FIRST. NOTHING COUNTS EITHER NEW SURFACE. `.config/nextest.toml`, `.cargo/mutants.toml` and the two workflows that call them stand outside both published denominators and outside the repository-laws roster, so deleting any of them fails no stage of `cargo xtask qualify` and is caught by review alone. That is the one admission requirement of the six that neither surface arrives with. The exact opening condition — a law of the shape `dependency-gate-artifacts-are-present-and-distinct`, reading these four files and refusing when one goes, is emptied, or stops departing from the rule it stands against — is written in `.config/nextest.toml` and cited from `.cargo/mutants.toml`. It is not written here because the checks it belongs beside are owned by another branch in flight. And exactly as with the dependency gate, such a law would establish that the FILES are there and never that a hosted step still runs them. THE NEXTEST REVERSAL COVERS LESS THAN THE FILE IT GUARDS. It establishes that the configuration is read, that a profile in it is applied, that a filterset written there decides what runs, and that a refusal exits with a diagnostic. It establishes nothing about `fail-fast`, `retries` or `failure-output`, which stand on a positive invocation alone. The mutation job's counting guard is written twice, once per step, and a change made to one and not the other is caught by review and by nothing else. THE SURVIVING MUTANTS ARE NOT REPAIRED HERE. They are the finding, they are evidence debt, and each is owed at the home of the source that carries it. Two of them are in `xtask/src/qualification.rs`, which this change owns and did not repair either: a repair is a test, and a test written in the same breath as the measurement that demanded it is a test aimed at a number. THREE SEMANTIC SILENT FALLBACK SITES ARE NAMED AND NOT REPAIRED, for the same reason and because each sits in a file another branch owns. They are written into `clippy.toml`, beside the record whose opening condition asked for them. DENOMINATORS, BEFORE AND AFTER, MEASURED ON BOTH SIDES OF THIS DIFF: red twins (core) 19 discharged / 178 owed unchanged tooling reversals 18 discharged / 3 owed unchanged repository laws 17 unchanged qualification stages 7 unchanged Nothing moves because this diff adds no obligation row and no testpak file — the two populations both ledgers are drawn from — and because nextest does not enter the stage table. The `before` figures were taken by stashing this work and running `cargo xtask check` on the clean checkout. THE MUTATION RUN, EXECUTED. `cargo mutants --workspace` over the whole declared `xtask` scope, on a working machine, at two jobs: 648 mutants in 15 minutes: 226 caught, 49 survived, 0 timed out, 373 did not build The scope is the whole of `xtask`, so nothing in the declared scope was skipped. The machine, the services and the judge are out of scope by decision, written at the config. Survivors by file: placement 16, vocabulary 9, manifest 7, seal 6, mint 3, qualification 2, supply_chain 2, walk 1, readme 1, obligations 1, coupling 1. The shapes repeat: a boolean operator flipped inside a character classifier, a match arm deleted from a hand-written scanner, a comparison negated. `referenced_heads` in `xtask/src/checks/placement.rs` alone survives 13 distinct damages — every arm of its brace-and-colon walk can be deleted or inverted and no test notices. THE NEXTEST PARITY MEASUREMENT, EXECUTED BEFORE ADOPTION: cargo test --locked --workspace 532 harness tests + 3 doctests cargo nextest run --locked --workspace 532 harness tests + 0 doctests The two executed sets were compared name by name and the difference is empty in both directions. Nothing changed hands and nothing was dropped. The three doctests are `closed_register`, `CLOSED_REGISTER_ROW_CEILING` and `identity::scope_guard_version`, and the second of them is the one place the stamp's authoring profile is written down. SO NEXTEST SUPPLEMENTS AND DOES NOT REPLACE. The `tests` stage is untouched and still executes all 535 on both hosts, which is why no doctest coverage is deleted by anything here. The reason it may not become a stage is the repository's own, already written in `deny.toml`: a stage needing a separately installed binary would make the entry bar depend on what a machine happens to have. The harness job therefore runs a strict SUBSET of the bar — 532 of 535 — deliberately, with no doctest leg of its own, because a third execution of a control the bar already runs twice would be one claim seated twice. THE FALLBACK CLASSIFICATION, the first of the two conditions `clippy.toml` states for its `disallowed-methods` table. Every call of `Result::unwrap_or_default`, `Option::unwrap_or_default`, `String::from_utf8_lossy` and `Result::ok` was read at its own site and placed by elimination against the four kinds. The first kind — the only defect — has three members, named in `clippy.toml` by file and by function rather than by line, because a named site can be checked by opening the file and a number cannot be checked by opening anything: - `src/00_refusal/types.rs`, `DeclaredCauseOrder::ordinal_of` - `xtask/src/checks/vocabulary.rs`, `check_no_personal_names` - `xtask/src/checks/vocabulary.rs`, `check_banned_vocabulary` The third kind turns out to be EMPTY: no operational host read in this tree is spelled with any of the four methods. The second condition is untouched, so the table is still not adopted and `clippy.toml` says why in the same breath. NO TOTAL WAS WRITTEN INTO `clippy.toml`, per its own argument about counts. WHAT LANDED. .config/nextest.toml the second harness, its claims, its nonclaims, the measurement above, and the `reversal` profile that proves the file is read .cargo/mutants.toml the mutation scope, `cap_lints`, and a report directory that cannot dirty a checkout .github/workflows/harness.yml nextest on every change, plus the planted reversal: exit 100 and `timed out`, both required, both MEASURED .github/workflows/mutation.yml scheduled only, conditioned by a POSITIVE list, survivors printed and never fatal, and a planted empty scope its own guard must refuse clippy.toml the classification, and why the table still stays out deny.toml what this file does not reach, now that three jobs install a tool from outside its graph deny-reversal.toml an authored count of its own kind, deleted .github/workflows/qualify.yml, dependencies.yml two sentences that counted the trunk's bootstrap triggers, and a third one now exists FOUND OUTSIDE SCOPE, NAMED AND NOT WIDENED INTO. `cargo mutants` writes `mutants.out` beside `Cargo.toml` by default, which would fail the qualification road's closing stage on any working machine that ran it. The config redirects it into `target`; `.gitignore` was left alone. `referenced_heads`, `strip_comment`, `quoted_assignment` and `split_identifier_words` are hand-written character scanners whose survivors say the same thing about each: their tests exercise the shapes they were written for and not the classifier underneath. That is a test-shape finding for the homes that own them, not a defect this diff repairs. Co-Authored-By: Claude Opus 5 (1M context) --- .cargo/mutants.toml | 96 +++++++++++++++ .config/nextest.toml | 181 +++++++++++++++++++++++++++++ .github/workflows/dependencies.yml | 11 +- .github/workflows/harness.yml | 143 +++++++++++++++++++++++ .github/workflows/mutation.yml | 170 +++++++++++++++++++++++++++ .github/workflows/qualify.yml | 11 +- clippy.toml | 81 +++++++++++++ deny-reversal.toml | 11 +- deny.toml | 13 +++ 9 files changed, 706 insertions(+), 11 deletions(-) create mode 100644 .cargo/mutants.toml create mode 100644 .config/nextest.toml create mode 100644 .github/workflows/harness.yml create mode 100644 .github/workflows/mutation.yml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..1937dea --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,96 @@ +# 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: NOWHERE YET, and it is the one admission +# requirement this surface arrives without. Its report has a denominator — the +# run derives it, every mutant examined, split into caught, survived, timed out +# and did-not-build, and the job prints it — but that denominator is published by +# a run rather than joined by a law, so nothing refuses when this file goes. The +# opening condition is written once, in `.config/nextest.toml`, and covers both +# surfaces: a repository law of the shape +# `dependency-gate-artifacts-are-present-and-distinct` reading these two configs +# and the two workflows that call them. + +# 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..49ac3e9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,181 @@ +# 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 532 harness tests and 3 +# doctests, and passes; +# - `cargo nextest run --locked --workspace` executes 532, 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. +# +# 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 535 on both hosts, and the doctests +# lose nothing. What follows is that this configuration's population is a strict +# SUBSET of the bar's — 532 of 535 — 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: NOWHERE YET, and that is the one admission +# requirement this surface arrives without. It is in neither published +# denominator, for the reason `deny-reversal.toml` gives about itself — the +# red-twin populations xtask joins against are testpak's tests and its +# compile-fail fixtures, and a configuration beside a hosted workflow is neither +# — and it is not yet in the other machine-owned roster either. The dependency +# gate IS: `dependency-gate-artifacts-are-present-and-distinct` reads that gate's +# committed files and refuses when one is deleted or when its reversal stops +# departing from the rule it stands against. Nothing reads THESE files. +# +# THE OPENING CONDITION, EXACTLY: a repository law of that same shape, reading +# this file, `.cargo/mutants.toml`, and the two workflows that call them, and +# refusing when one is deleted, when one is emptied, or when the `reversal` +# profile below stops departing from `default`. Until it exists, deleting any of +# them fails no stage of `cargo xtask qualify` and is caught by review alone — +# and even with it, what such a law establishes is that the FILES are there, not +# that any hosted step still runs them. That second half is the same road the +# dependency gate names and does not reach. + +# 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`. `compile_refusals` shells out to +# rustc once per compile-fail fixture and MEASURED takes 17 seconds on a warm +# working machine — a cold hosted runner with no cache, which is what this +# repository deliberately runs, is slower by a factor nobody here has measured. +# 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 the day a hosted run publishes +# per-test durations to set it against, and not before. +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, and it is deliberately NOT a timing race. The profile selects +# the one test in this repository that is known to be slow — `compile_refusals` +# shells out to rustc once per compile-fail fixture and MEASURED takes 17 seconds +# on a warm working machine — and then sets a kill threshold of one second +# against it. The margin is a factor of seventeen on the fastest host anybody has +# run this on, and a cold hosted runner only widens it. Both halves are stated +# HERE rather than in the step that runs them, so the reversal is one artifact +# and a run of it needs no arguments to be the reversal. +# +# MEASURED, on the pinned toolchain: `cargo nextest run --locked --workspace +# --profile reversal` reports `1 timed out`, prints `TIMEOUT` against the test, +# and exits 100. The step requires BOTH, for the reason the dependency gate's +# reversal step requires both: nextest exits non-zero for a usage error and for +# a configuration it could not read as well, so an exit code alone would accept +# a run that never reached a test. Exit 100 with `timed out` is the one pair +# that means it refused for the reason that was planted. +# +# THE ONE WAY THIS REVERSAL CAN STOP BEING ABOUT ANYTHING is `compile_refusals` +# becoming fast, or leaving. It does not go silent when that happens: the step +# requires a REFUSAL, so a run that suddenly passes turns the job red rather than +# green, and the day the compile-refusal harness is retired is a day this profile +# is rewritten on purpose. +# +# 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, 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 = 'binary(compile_refusals)' +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..a1265bd --- /dev/null +++ b/.github/workflows/harness.yml @@ -0,0 +1,143 @@ +# 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 — 180 tests rather than 532 — 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. + - 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: + # 532 of the 535 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. + # + # BOTH the exit code and the diagnostic are required, because neither is + # enough alone. nextest exits non-zero for a usage error and for a + # configuration it could not read as well, so the code by itself would + # accept a run that never reached a test. MEASURED on the pinned + # toolchain: exit 100 with `timed out` is the one pair that means it + # refused for the reason that was planted. + # + # 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 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 + echo "the planted reversal refused: exit 100, timed out" 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/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 From 6aab3d1c092a7b2a1b04cdc8df5c690f08f80cef Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 00:34:18 -0400 Subject: [PATCH 3/9] Seat every sealed record in a module of its own; retire the two readers that guessed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two repository laws — `stamped-guards-seal-their-position` and `refusal-mints-are-inside-the-plane` — asked, of a whole file at a time, whether anybody had written a road that hands a sealed value out. Answering that means resolving types, following aliases, deciding what a receiver stands for, and inferring reachability from visibility and module chains. Between them they were wrong twelve times, in twelve Rust shapes, and each repair taught one more shape while leaving the next open. The defect was never in the readers. Rust's privacy is MODULE-scoped, so a private field declared in a home's `types.rs` puts every other item in that file inside its wall — dozens of types — and the only remaining question is a whole-file audit. So the wall moved. WHAT MOVED `scope_guard_version!` now takes the module as an argument and emits the guard into it: pub struct FrameVersion over ReferenceFrameId, seated in mod frame_version; mod frame_version { pub struct FrameVersion(AuthorityPosition<..>); impl .. } pub use frame_version::FrameVersion; Nothing hand-written can enter 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. All twelve production guards moved — the count is derived from `grep scope_guard_version!` rather than trusted from a note: schema, history x2, navigation, port, declaration, execution x2, image x2, derived, application. The proof surface's demonstration guard moved with them and is stamped `pub(crate)` rather than bare, because a guard with no visibility at all would be sealed inside a module nothing can name. The module name is a call-site argument because `macro_rules!` cannot build an identifier from another identifier on stable and this repository carries no dependency that can. REJECTED: a dedicated per-band stamps file. It needs a third type-owned file in nine homes, which the file grammar does not admit, and it leaves the FILE rather than the expansion as the wall — hand-written code could still stand beside a guard, which is the whole defect. The `snake_case` module name is held by `non_snake_case` under the lint wall, with no attribute suppressing anything. The seven closed refusal bodies in the services took the same move, minus the macro: each is now DECLARED in a `mod seat` inside its home's `type_guard.rs`, carrying that record and the inherent implementations that reach its seat and nothing else, and re-exported out. `types.rs` publishes it and can no longer touch the field. The seven were derived, not trusted: every `pub struct` with at least one field, no public field, that some `Result`'s error position names — ProjectionPlanning, CompositionRootDeclaration, ExplanationCoverage, ProjectionClosureRefusal, TemplateConstruction, TriggerViewComposition, RefusalDeriveRefusal. The five mints that were module-private became `pub(super)` out of the seat, which is the same reach spelled one module in. RETIREMENT TABLE | New authority | Old mechanism deleted | Old claims moved | Old tests deleted | Residual ceiling | | --- | --- | --- | --- | --- | | rustc: `E0616` on the field, `E0423`/`E0603` on the constructor, over a module the stamp writes whole | `xtask/src/checks/seal.rs`, law `stamped-guards-seal-their-position` (1135 lines) | "no public road of a stamped guard hands its position back out" -> the compiler, for every road outside the expansion. "The seat is read off the stamp's own transcriber" -> deleted: the transcriber IS the module now, so there is nothing left to read it against | 13 | A road added to the TRANSCRIBER arrives on all twelve guards and nothing refuses it — the two laundering fixtures keep their diagnostics byte for byte, as their headers already said. The old law read the transcriber; no law does now. That is a real loss, stated rather than papered over; the repair bar is a one-line edit to `src/02_identity/mod.rs` under review | | `mod seat` + `seat-modules-carry-nothing-else` (`xtask/src/checks/seat.rs`) | `xtask/src/checks/mint.rs`, law `refusal-mints-are-inside-the-plane` (1392 lines) | "a closed refusal body is minted only from inside the crate" -> `testpak/tests/compile-fail/a-services-refusal-minted-outside-its-plane.rs`, ten roads, `E0624` each, plus the seat module now being small enough to read whole. "The population is derived" -> kept, over `seat` modules rather than over bodies, denominator printed on every run | 19 | The law does not decide WHICH records must be seated: a closed record in a module named anything else is outside its population, and the fixture is what names these seven. It does not read what a road INSIDE a seat module returns — what the move buys is that every such road sits in one small module that cannot grow sideways | The replacement law is a pure syntax question and cannot be wrong: for every module named `seat`, exactly one struct, and beyond that only `use` items and inherent `impl` blocks whose subject is spelled like that struct. It resolves no type, follows no alias, expands no macro, and reads no visibility. The population is derived from the tree at any depth; no literal count stands anywhere, in the check or in its tests. EXECUTED REVERSALS Four roads planted in `src/11_navigation/types.rs`, compiled, recorded, removed. Each is a shape the deleted reader lost to, and each is now a COMPILER refusal: error[E0616]: field `0` of struct `FrameVersion` is private --> src/11_navigation/types.rs:821:13 pub fn take(v) -> AuthorityPosition --> src/11_navigation/types.rs:827:22 -> Box --> src/11_navigation/types.rs:836:13 -> LaunderedPosition (a type alias) --> src/11_navigation/types.rs:841:15 impl AsRef for FrameVersion The false refusal is gone, executed: a PRIVATE module-local trait whose method hands back a closed refusal body, implemented for a producer and reached on a live path in `macros/macroc/src/pattern_stamp/plan.rs`. It compiles clean under the lint wall and every repository law passes. The deleted reader called every trait road reachable and stopped a build over a road no outside caller has. The new law's own reversal, executed: one hand-written `fn` inside the `trigger_view` seat module. seat modules: 6 carrying one record alone / 7 declared FAIL seat-modules-carry-nothing-else: macros/macroc/src/trigger_view/type_guard.rs: a `seat` module carries a free function, 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 DENOMINATORS, BEFORE -> AFTER repository laws 17 -> 16 red twins (core) 19 discharged / 178 owed -> unchanged tooling reversals 18 discharged / 3 owed -> unchanged collection bodies 27 coupled / 27 declared -> unchanged stamped scope guards 12 sealed / 12 stamped -> denominator retired; 12 still stamped, each in its own module, held by rustc refusal mints 17 roads / 7 closed bodies -> denominator retired; 7 bodies, each seated seat modules (none) -> 7 carrying one record alone / 7 declared xtask reversal tests 32 deleted (13 seal + 19 mint), 15 added (13 seat + 2 coupling) FOUND AND REPAIRED IN SCOPE `collection-bodies-are-coupled` resolved a family's body in the declaring home and treated every inline module as its own scope, so moving a body into `mod seat` dropped its population from 27 to 21 and produced six false refusals. A re-export is what puts a name in the enclosing scope, so `read_module` now reads a body the enclosing module re-exports out of a child into that scope as well — purely syntactic, one `use` tree, globs excluded. Two planted controls added. This is correctness closure of this change, not a widening of it. `proc-macro2` is removed from `xtask` and from `[workspace.dependencies]`: it arrived for the seal law's transcriber substitution, and no reading here builds a token stream any more. FOUND, NOT REPAIRED The `a-stamped-representation-cannot-be-laundered` fixture's constructor half moved from `E0603` (private tuple struct constructor) to `E0423` (cannot initialize a tuple struct which contains private fields). Same road, same refusal, a more direct diagnostic — the name is a re-export whose constructor's field is private, rather than a private path. Recorded in the regenerated `.stderr`; no claim depended on the code. `cargo xtask qualify`: all 7 stages green on these bytes. --- Cargo.lock | 1 - Cargo.toml | 9 - macros/macroc/README.md | 75 +- macros/macroc/src/closure/type_guard.rs | 118 +- macros/macroc/src/closure/types.rs | 35 +- macros/macroc/src/composition/type_guard.rs | 122 +- macros/macroc/src/composition/types.rs | 32 +- .../macroc/src/derive_refusal/type_guard.rs | 87 +- macros/macroc/src/derive_refusal/types.rs | 19 +- .../src/explanation_protocol/type_guard.rs | 125 +- .../macroc/src/explanation_protocol/types.rs | 38 +- macros/macroc/src/refusal/type_guard.rs | 207 ++- macros/macroc/src/refusal/types.rs | 56 +- macros/macroc/src/template/type_guard.rs | 163 +- macros/macroc/src/template/types.rs | 34 +- macros/macroc/src/trigger_view/type_guard.rs | 116 +- macros/macroc/src/trigger_view/types.rs | 33 +- src/02_identity/README.md | 66 +- src/02_identity/mod.rs | 165 +- src/08_schema/types.rs | 2 +- src/10_history/types.rs | 10 +- src/11_navigation/README.md | 6 +- src/11_navigation/types.rs | 2 +- src/12_port/types.rs | 2 +- src/13_declaration/types.rs | 2 +- src/15_execution/types.rs | 4 +- src/16_image/types.rs | 4 +- src/20_derived/types.rs | 2 +- src/21_application/types.rs | 2 +- src/laws.rs | 14 +- testpak/README.md | 2 +- ...duction-scope-guard-cannot-be-laundered.rs | 12 +- ...ion-scope-guard-cannot-be-laundered.stderr | 16 +- ...rvices-refusal-minted-outside-its-plane.rs | 20 +- ...es-refusal-minted-outside-its-plane.stderr | 108 +- ...mped-representation-cannot-be-laundered.rs | 4 +- ...-representation-cannot-be-laundered.stderr | 25 +- ...me-comparison-on-a-production-guard.stderr | 10 +- ...oss-scope-comparison-on-a-stamped-guard.rs | 4 +- ...scope-comparison-on-a-stamped-guard.stderr | 2 +- xtask/Cargo.toml | 16 +- xtask/src/checks/coupling.rs | 136 +- xtask/src/checks/mint.rs | 1392 ----------------- xtask/src/checks/mod.rs | 4 +- xtask/src/checks/seal.rs | 1135 -------------- xtask/src/checks/seat.rs | 586 +++++++ xtask/src/main.rs | 13 +- 47 files changed, 1751 insertions(+), 3285 deletions(-) delete mode 100644 xtask/src/checks/mint.rs delete mode 100644 xtask/src/checks/seal.rs create mode 100644 xtask/src/checks/seat.rs diff --git a/Cargo.lock b/Cargo.lock index 4dadc45..ce906b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,7 +353,6 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" name = "xtask" version = "0.0.0" dependencies = [ - "proc-macro2", "syn", ] diff --git a/Cargo.toml b/Cargo.toml index 0615bac..5736720 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,15 +74,6 @@ 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. -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 # rename every identity in the tree. Default features off: `std` buys the 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/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/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/08_schema/types.rs b/src/08_schema/types.rs index b0fea55..c834ca6 100644 --- a/src/08_schema/types.rs +++ b/src/08_schema/types.rs @@ -64,7 +64,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. diff --git a/src/10_history/types.rs b/src/10_history/types.rs index 70c7aff..04d4cc7 100644 --- a/src/10_history/types.rs +++ b/src/10_history/types.rs @@ -83,14 +83,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 +122,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 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..b29f7a4 100644 --- a/src/11_navigation/types.rs +++ b/src/11_navigation/types.rs @@ -57,7 +57,7 @@ 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. diff --git a/src/12_port/types.rs b/src/12_port/types.rs index 114744c..5d3df59 100644 --- a/src/12_port/types.rs +++ b/src/12_port/types.rs @@ -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; } // --------------------------------------------------------------------------- diff --git a/src/13_declaration/types.rs b/src/13_declaration/types.rs index 61eda4a..c1d3b36 100644 --- a/src/13_declaration/types.rs +++ b/src/13_declaration/types.rs @@ -197,7 +197,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 diff --git a/src/15_execution/types.rs b/src/15_execution/types.rs index 2ec1a9d..7294cf6 100644 --- a/src/15_execution/types.rs +++ b/src/15_execution/types.rs @@ -116,7 +116,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; } // --------------------------------------------------------------------------- @@ -757,7 +757,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). diff --git a/src/16_image/types.rs b/src/16_image/types.rs index 2952b23..5713749 100644 --- a/src/16_image/types.rs +++ b/src/16_image/types.rs @@ -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. 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/laws.rs b/src/laws.rs index b8b9be6..cc6ce0f 100644 --- a/src/laws.rs +++ b/src/laws.rs @@ -1565,13 +1565,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 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-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-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/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/xtask/Cargo.toml b/xtask/Cargo.toml index c8adeea..e6c6a38 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -38,14 +38,12 @@ 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` used to stand beside it, for a law that read 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. No reading here +# builds a token stream any more, so the mechanism goes with the law that earned +# it rather than sitting in the manifest as an edge nobody uses. [dependencies] -proc-macro2 = { workspace = true } syn = { workspace = true } diff --git a/xtask/src/checks/coupling.rs b/xtask/src/checks/coupling.rs index da7dc68..862577e 100644 --- a/xtask/src/checks/coupling.rs +++ b/xtask/src/checks/coupling.rs @@ -322,17 +322,91 @@ fn read_module(path: &str, home: &str, items: &[syn::Item], reading: &mut Readin }); } } - syn::Item::Mod(module) => { - if let Some((_, inner)) = &module.content { - let inside = format!("{path}::{}", module.ident); - read_module(path, &inside, inner, reading); - } - } + syn::Item::Mod(module) => 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), + }); + } + } +} + /// The type one implementation declares the collection shape for, or `None` /// where the implementation is not a collection-shaped family declaration. /// @@ -468,6 +542,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] 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..f71c6e3 100644 --- a/xtask/src/checks/mod.rs +++ b/xtask/src/checks/mod.rs @@ -24,9 +24,7 @@ pub(crate) mod obligations; pub(crate) mod coupling; -pub(crate) mod mint; - -pub(crate) mod seal; +pub(crate) mod seat; pub(crate) mod vocabulary; 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..8fc008a --- /dev/null +++ b/xtask/src/checks/seat.rs @@ -0,0 +1,586 @@ +//! 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; whether some other `Foo` is +//! in scope is a question this law never asks, because the answer cannot change +//! the verdict — the seat module declares exactly one record, and an inherent +//! implementation written inside it can only be for something declared or +//! imported there. +//! +//! # 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. + +use std::fs; +use std::path::Path; + +use crate::repository::walk::{TOOLING_DIRECTORY, relative_slash_path, visit_files}; + +/// 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(root: &Path) -> Result<(), String> { + let sources = seat_sources(root)?; + 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 source text and judges each one. +/// +/// Pure over its inputs — `(repository-relative path, source text)` pairs — so +/// the reversals below are planted in memory and the law that guards the seats +/// is never proven by opening one. +fn seat_verdict(sources: &[(String, String)]) -> SeatVerdict { + let mut verdict = SeatVerdict { + declared: 0, + closed: 0, + offenders: Vec::new(), + }; + for (path, text) in sources { + match syn::parse_file(text) { + Ok(file) => walk(path, &file.items, &mut verdict), + Err(error) => verdict.offenders.push(format!( + "{path}: this file is not parseable Rust, so the population derived from it is \ + unknown rather than empty: {error}" + )), + } + } + verdict +} + +/// Walks one module's items, judging every `seat` module it declares and +/// descending into every other module to find the ones nested deeper. +fn walk(path: &str, items: &[syn::Item], verdict: &mut SeatVerdict) { + for item in items { + let syn::Item::Mod(module) = item else { + 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); + } +} + +/// 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; + } + let Some(subject) = head_of(&declared.self_ty) else { + 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" + )); + return; + }; + 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" + )); + } +} + +/// 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" +} + +/// 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 { + typed + .path + .segments + .last() + .map(|last| last.ident.to_string()) + } else { + None + } +} + +/// Every source file the population is derived from: the machine's own sources +/// and the services'. +/// +/// 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(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 text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + sources.push((relative_slash_path(root, path), text)); + Ok(()) + })?; + } + Ok(sources) +} + +/// 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}; + use crate::repository::walk::repo_root; + + /// 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 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); + } + + /// 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() { + let read = repo_root() + .map_err(|error| format!("the repository root could not be found: {error}")) + .and_then(|root| seat_sources(&root)) + .map(|sources| seat_verdict(&sources)); + assert!( + read.is_ok(), + "the seat 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.declared > 0), + "no `seat` module found in the real tree: {read:?}" + ); + assert!( + read.is_ok_and(|verdict| verdict.closed == verdict.declared), + "the real tree carries a `seat` module with something else in it" + ); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cefe709..0467201 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -38,11 +38,10 @@ 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::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}; @@ -63,7 +62,7 @@ fn main() -> Result<(), Box> { /// Runs every repository law, printing one PASS or FAIL line per law. fn run_checks(root: &Path) -> Result<(), Box> { - let checks: [Check; 17] = [ + let checks: [Check; 16] = [ ("agents-claude-parity", check_agents_claude_parity), ("lf-and-no-symlinks", check_lf_and_no_symlinks), ("no-python", check_no_python), @@ -87,12 +86,8 @@ 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, - ), - ( - "stamped-guards-seal-their-position", - check_stamped_guards_seal_their_position, + "seat-modules-carry-nothing-else", + check_seat_modules_carry_nothing_else, ), ("no-personal-names", check_no_personal_names), ("banned-vocabulary", check_banned_vocabulary), From 824817c5489a84e8e4cc45da1b87464ab5d04854 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 00:44:03 -0400 Subject: [PATCH 4/9] Seat every reading in the decoder that owns its language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository is read ONCE, into one immutable snapshot, and every law is a pure function over it. No law walks the tree, opens a file, or starts a process; no reading has a fallback; and no reader re-derives a fact a stronger reader already owns. The defect this ends is oracle inversion. A line reader stood where Cargo's own decoder belongs: it cut a line at its first `=` and read the head as a package name, so `threadpak-macroc.workspace = true` named a package that does not exist. Eleven spellings escaped it across one campaign — dotted keys, quoted keys, bracketed-string headers, escaped keys, literal strings, multi-line strings, four-quote terminators, unicode escapes, inline tables, a key carrying an equals sign, and a comment after a header — and each repair revealed the next, because a spelling admitted one at a time is a set with no last member. The same shape stood over Markdown (a fenced block chosen by counting fences), over Rust (a line scan for an underscore field, a substring scan for a `#[path]` attribute, three separate parses of the same sources), and over the resolved dependency graph (manifest text answering a question only cargo can answer). The technique was applied correctly at each site and never elevated to the class. This elevates it to the class. RETIREMENT TABLE | New authority | Old mechanism deleted | Old claims moved | Old tests deleted | Residual ceiling | | --- | --- | --- | --- | --- | | `repository/cargo.rs` — the `toml` decoder for what a manifest DECLARES, `cargo metadata --locked --format-version 1` for what cargo RESOLVES | `repository/manifest.rs`, whole file: `dependency_declarations`, `key_path`, `strip_comment`, `quoted_assignment`, `quoted_text`, `quoted_value`, `bracket_list`, `seat`, `dependency_position`, `after_target`, `unenterable_table`, and the inline-table refusal | topology (both parts, now over both authorities), toolchain floor, workspace members, lint-wall inheritance | eleven per-spelling reversals in `dependency.rs` collapse into one proof over the class; `an_inline_dependency_table_is_refused_unread`; `a_multi_line_string_quoting_a_table_is_read_as_that_table`, whose ceiling is now closed | the census is over COMMITTED manifests; a manifest cargo generates is not one | | `repository/markdown.rs` — `pulldown-cmark` for document structure, blocks selected by declared SCHEMA | `repository/readme.rs`, whole file: `readme_yaml_block` (the first-fence rule), `classify_green_rows` / `red_twin_rows` / `tooling_red_rows` (whole-file scans), `obligation_records` and `indentation` (the indentation grammar) | obligation ledger, tooling ledger, phase declaration | `readme.rs`'s fourteen row tests; `unowned_row_offences` and its two tests | YAML flow style, anchors, aliases and block scalars are read as the lines they are written on, and fail CLOSED. No YAML decoder can be admitted: measured against the committed lock, `yaml-rust2` resolves `hashbrown` 0.16 beside the 0.17 this graph holds, and `saphyr` / `saphyr-parser` reach `thiserror`, which requires `syn` 2 beside the pinned `syn` 3 — each breaks `multiple-versions = "deny"`. The ledger blocks are also yaml-SHAPED rather than YAML: the tooling ledgers write a scalar mapping value followed by more-indented keys, which no YAML decoder accepts | | `repository/rust.rs` — every `.rs` file parsed once | the four separate `syn::parse_file` walks in `coupling`, `mint`, `seal` and `obligations`; `walk.rs::module_source`, which concatenated a directory module's files into one text | coupling, mint, seal, seat populations | — | syntax only: no resolution, no `cfg` evaluation, no expansion, no alias resolving | | `repository/snapshot.rs` — one walk, one `Read` per fact | `repository/walk.rs`, whole file: `visit_files`, `relative_slash_path`, `SKIP_DIRS`; every `fs::read` / `read_to_string` / `read_dir` in `checks/`; the obligation join's own directory listing | every law's population | — | the file map is the WALK and not git's index, so an ignored file is still judged | | syn-seated `underscore-fields-are-phantom` | the line scan for a trimmed line opening with an underscore and carrying a colon | same law, narrowed to what a parse establishes: declared FIELDS | — | a field inside a `macro_rules!` transcriber is not a field until expansion; an alias that resolves TO `PhantomData` is not recognized; fixtures BENEATH `testpak/tests/` are outside the subject, as they already are in the obligations join | | syn-seated `band-map-matches-lib`, syn and `proc-macro2`-seated `tooling-module-order` | `str::find` over a literal `#[path]` spelling; `mod ` matched at the head of a trimmed line; `crate::` and `super::` matched as substrings of a concatenated text | same laws | — | a rustdoc intra-doc link is still read as TEXT, and that is now the one text reading, stated at the site; runtime-composed paths remain outside | | toml-seated `lint-wall-inherited` | a substring match for the inheritance table | same law | — | — | | `Read` = `Known` / `DeclaredAbsent` / `Unreadable` | `unwrap_or_default()` on a manifest, a README, a laws file and a README list; the root fallback at fifteen sites; the empty-population repairs behind them | — | — | — | The one `#[expect]` in the tree is gone with the match that needed it. No `#[allow]` or `#[expect]` remains anywhere in `xtask/`. DENOMINATORS, BEFORE AND AFTER — every one unchanged red twins (core) 19 discharged / 178 owed -> 19 / 178 tooling reversals 18 discharged / 3 owed -> 18 / 3 collection bodies 27 coupled / 27 declared -> 27 / 27 refusal mints 17 roads / 7 bodies -> 17 / 7 stamped scope guards 12 sealed / 12 stamped -> 12 / 12 obligation records 197 across 25 home READMEs -> 197 across 25 green rows 180 seats, 16 dispositions, 1 route, 0 unreadable -> same repository laws 17 registered, 17 PASS -> 17 / 17 MANIFEST CENSUS: 15 -> 19, and the four that moved are the four this work admitted. Measured before the change across all seven committed manifests: 15 entries, the root manifest declaring none of them because what it carries is a workspace POOL. After: 19, the difference being `toml`, `pulldown-cmark`, `serde` and `serde_json`, every one of them an entry of `xtask/Cargo.toml`. Same manifests, same kinds, same keys, same declared packages and paths everywhere else. `the_committed_census_is_nineteen_entries` pins it entry for entry. ADMITTED MECHANISMS. `toml`, `pulldown-cmark`, `serde` and `serde_json`, pinned exact with default features off and the reason written beside each. Three crates are new to the resolved graph — `pulldown-cmark`, `bitflags`, `unicase` — and all three are `MIT OR Apache-2.0`, already on `deny.toml`'s allow list, so no licence or feature policy needed changing and no duplicate version enters the lock. FOUND, NOT REPAIRED - `xtask/src/checks/mint.rs` carries one `unwrap_or` about which type a receiver stands for. It is a declared choice rather than a repair of an unread fact, and it is left where it is. - `testpak/src/*/README.md` declare seat-reservation data blocks that no reading joins. They now carry a schema identity and are counted as recognized; nothing reads their content, and nothing pretends to. - The branch `manifest-multiline-refusal` (PR #18) is SUPERSEDED. It repairs the hand parser this commit deletes; the ceiling it was written against — a multi-line string quoting a dependency table — is closed here by the decoder, and `a_spelling_that_changes_the_declaration_changes_it_exactly` pins the new answer. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 28 + Cargo.toml | 25 + xtask/Cargo.toml | 40 ++ xtask/src/checks/coupling.rs | 198 +++--- xtask/src/checks/dependency.rs | 729 ++++++++------------- xtask/src/checks/hygiene.rs | 350 +++++++--- xtask/src/checks/mint.rs | 142 ++-- xtask/src/checks/obligations.rs | 660 ++++++++++--------- xtask/src/checks/parity.rs | 44 +- xtask/src/checks/placement.rs | 664 ++++++++++++------- xtask/src/checks/scratch.rs | 35 +- xtask/src/checks/seal.rs | 137 ++-- xtask/src/checks/supply_chain.rs | 107 ++- xtask/src/checks/toolchain.rs | 200 +++--- xtask/src/checks/vocabulary.rs | 90 ++- xtask/src/main.rs | 75 ++- xtask/src/qualification.rs | 14 +- xtask/src/repository/cargo.rs | 809 +++++++++++++++++++++++ xtask/src/repository/manifest.rs | 355 ---------- xtask/src/repository/markdown.rs | 1056 ++++++++++++++++++++++++++++++ xtask/src/repository/mod.rs | 23 +- xtask/src/repository/readme.rs | 1032 ----------------------------- xtask/src/repository/rust.rs | 104 +++ xtask/src/repository/snapshot.rs | 473 +++++++++++++ xtask/src/repository/types.rs | 254 ++++++- xtask/src/repository/walk.rs | 110 ---- 26 files changed, 4725 insertions(+), 3029 deletions(-) create mode 100644 xtask/src/repository/cargo.rs delete mode 100644 xtask/src/repository/manifest.rs create mode 100644 xtask/src/repository/markdown.rs delete mode 100644 xtask/src/repository/readme.rs create mode 100644 xtask/src/repository/rust.rs create mode 100644 xtask/src/repository/snapshot.rs delete mode 100644 xtask/src/repository/walk.rs 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 0615bac..653e757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,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/xtask/Cargo.toml b/xtask/Cargo.toml index c8adeea..f674d22 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -46,6 +46,46 @@ workspace = true # 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. +# +# 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/coupling.rs b/xtask/src/checks/coupling.rs index da7dc68..4272081 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,35 @@ 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, - }); - } - } - 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), - }); - } + 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::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::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), + }); } - _ => {} + } else if let syn::Item::Mod(module) = item + && let Some((_, inner)) = &module.content + { + let inside = format!("{path}::{}", module.ident); + read_module(path, &inside, inner, reading); } } } @@ -402,29 +390,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 +416,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 +430,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. @@ -874,27 +882,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:?}" - ); - assert!( - read.as_ref() - .is_ok_and(|verdict| verdict.offenders.is_empty()), - "{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.declared > 0), - "no collection-shaped family found in the real tree: {read:?}" + verdict.declared > 0, + "no collection-shaped family found in the real tree: {verdict:?}" ); - 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 6221d79..f65fd36 100644 --- a/xtask/src/checks/dependency.rs +++ b/xtask/src/checks/dependency.rs @@ -2,23 +2,42 @@ //! //! 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 RESOLVES** is a different question, and the manifests cannot +//! answer it: an edge can arrive through workspace inheritance, and a package +//! identity is something only the resolver settles. `cargo metadata` is asked +//! directly. +//! +//! Neither 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 resolution that +//! never happened is the exact silence this repository is eliminating. -use crate::repository::manifest::dependency_declarations; -use crate::repository::walk::{JUDGE_DIRECTORY, TOOLING_DIRECTORY}; +use crate::repository::cargo::{DeclaredDependency, MANIFEST_FILE, ResolvedWorkspace}; +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,37 +47,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. -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 resolved edge is refused where cargo resolves +/// 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 resolution +/// 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 resolved = snapshot + .cargo() + .resolved() + .required("what cargo resolved for this workspace")?; + reported.extend(resolved_offences( + resolved, + CORE_PACKAGE, + judge_core_package, + "core package reaches tooling or its judge, as cargo resolved it", + )?); + reported.extend(resolved_offences( + resolved, + SERVICES_PACKAGE, + judge_services_package, + "services reach their expansion surface, as cargo resolved it", + )?); if reported.is_empty() { Ok(()) } else { @@ -66,258 +107,227 @@ 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(|spelling| unread_table("core", spelling)), - ); - 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(|spelling| unread_table("services", spelling)), - ); - 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 a dependency table written as an inline table commits. +/// Every offence one RESOLVED package commits. /// -/// Not an edge — a manifest this law cannot read. Its entries sit inside one -/// line's value, so the absence this law 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. -fn unread_table(manifest: &str, spelling: &str) -> String { - 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" - ) +/// The resolved reading judges package IDENTITY and nothing else. A resolved +/// path 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 resolved_offences( + resolved: &ResolvedWorkspace, + package: &str, + judge: fn(&str) -> Option, + claim: &str, +) -> Result, String> { + let found = resolved + .package(package) + .taken(&format!("the package `{package}` in what cargo resolved"))?; + let mut offences = Vec::new(); + for edge in found.dependencies() { + 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 resolved 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 resolved 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 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. /// -/// 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. +/// 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 @@ -328,186 +338,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. + /// is not an invariant. The decoder is the invariant. #[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. - #[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. + /// 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_refused_unread() { - let at_root = core_tooling_edge_violations( - "dependencies = { threadpak-macroc = { path = \"macros/macroc\" } }\n\n\ - [package]\nname = \"threadpak\"\n", - ); + 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"))); - } - - /// The ceiling, executed rather than only written down: a multi-line - /// string whose body reads like a dependency table is read as one. - /// - /// The reader is line-oriented and cannot see that these lines sit inside - /// a value, so the phantom edge quoted in the string is reported as an - /// edge. That is the wrong answer, and this test is here to state which - /// way it is wrong: a lawful manifest is REFUSED, never a prohibited one - /// passed. The opposite direction is closed by cargo rather than by this - /// reader, and the module documentation carries the measurement. A later - /// reader that resolves manifests properly has to delete this test to do - /// it, and that deletion is where the ceiling is allowed to lift. - #[test] - fn a_multi_line_string_quoting_a_table_is_read_as_that_table() { - let found = violations( - "description = \"\"\"\n[dependencies]\n\ - threadpak-macroc = { path = \"macros/macroc\" }\n\"\"\"\n", - ); - assert_eq!(found.len(), 1, "{found:?}"); + Ok(()) } /// 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 @@ -517,99 +390,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. + /// 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_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:?}"); + 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..83c681f 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,51 @@ 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. +/// +/// Two things a parse cannot reach, both failing CLOSED: a field written inside +/// a `macro_rules!` transcriber is not a field until it is expanded, and this +/// reader does not expand; and a type alias that resolves TO `PhantomData` is +/// not recognized, because resolving an alias is the compiler's question rather +/// than a parse's. Each costs a lawful field a refusal it has to spell +/// differently, and neither admits a field carrying data. +/// +/// # 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 +126,239 @@ 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))); + } + } + 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 !mentions_marker(&field.ty) { + into.push(spelled); + } +} + +/// Whether one declared type mentions the type-level marker, at any depth. +/// +/// A type this reader does not open contributes nothing, which is the +/// conservative direction here: it can refuse a lawful field, and it can never +/// admit one carrying data. +fn mentions_marker(declared: &syn::Type) -> bool { + if let syn::Type::Path(typed) = declared { + typed.path.segments.iter().any(|segment| { + segment.ident == TYPE_LEVEL_MARKER || marker_in_arguments(&segment.arguments) + }) + } else if let syn::Type::Reference(borrowed) = declared { + mentions_marker(&borrowed.elem) + } else if let syn::Type::Ptr(pointer) = declared { + mentions_marker(&pointer.elem) + } else if let syn::Type::Paren(parenthesized) = declared { + mentions_marker(&parenthesized.elem) + } else if let syn::Type::Group(grouped) = declared { + mentions_marker(&grouped.elem) + } else if let syn::Type::Tuple(tuple) = declared { + tuple.elems.iter().any(mentions_marker) + } else if let syn::Type::Array(array) = declared { + mentions_marker(&array.elem) + } else if let syn::Type::Slice(sliced) = declared { + mentions_marker(&sliced.elem) + } else { + false + } +} + +/// Whether one segment's arguments mention the type-level marker. +fn marker_in_arguments(arguments: &syn::PathArguments) -> bool { + if let syn::PathArguments::AngleBracketed(angled) = arguments { + angled.args.iter().any(|argument| { + if let syn::GenericArgument::Type(inner) = argument { + mentions_marker(inner) + } else { + false + } + }) + } else { + 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" + ); } } diff --git a/xtask/src/checks/mint.rs b/xtask/src/checks/mint.rs index f905823..a4ad9a5 100644 --- a/xtask/src/checks/mint.rs +++ b/xtask/src/checks/mint.rs @@ -141,10 +141,8 @@ //! 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}; +use crate::repository::snapshot::{RepositorySnapshot, TOOLING_DIRECTORY}; +use crate::repository::types::CanonicalPath; /// The proof surface, excluded from the population by name. /// @@ -178,8 +176,10 @@ const RESOLUTION_DEPTH: usize = 16; /// 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)?; +pub(crate) fn check_refusal_mints_are_inside_the_plane( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { + let sources = services_sources(snapshot)?; let verdict = mint_verdict(&sources); // The denominators are DERIVED and printed on every run, because a @@ -302,30 +302,23 @@ struct Reading { unresolvable: Vec, } -/// Reads the records, the refusals and the roads out of source text and judges +/// Reads the records, the refusals and the roads out of parsed trees 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 { +/// 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 opening a seat in 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 mint_verdict(parsed: &[(&CanonicalPath, &syn::File)]) -> 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 declarations = read_declarations(parsed); let Reading { refused, roads, unresolvable, - } = read_roads(&parsed, &declarations); + } = read_roads(parsed, &declarations); offenders.extend(unresolvable); let mut verdict = MintVerdict { bodies: 0, @@ -392,14 +385,14 @@ impl Road { } /// Every declaration the subsystem makes that a road is resolved against. -fn read_declarations(parsed: &[(String, syn::File)]) -> Declarations<'_> { +fn read_declarations<'a>(parsed: &[(&CanonicalPath, &'a syn::File)]) -> Declarations<'a> { 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); + read_declared_items(path.as_str(), &file.items, &mut declarations); } declarations } @@ -440,14 +433,17 @@ fn read_declared_items<'a>( } /// Every road the subsystem declares, judged against what it declares. -fn read_roads<'a>(parsed: &'a [(String, syn::File)], declarations: &Declarations<'a>) -> Reading { +fn read_roads<'a>( + parsed: &[(&CanonicalPath, &'a 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); + read_module(path.as_str(), &file.items, declarations, &mut reading); } reading } @@ -880,29 +876,27 @@ 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 +/// Every parsed source 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() { +/// +/// Taken from the one reading. A subsystem with no sources at all is an empty +/// population and says so downstream, and a source the snapshot could not parse +/// refuses the whole law rather than leaving the population one file short. +fn services_sources( + snapshot: &RepositorySnapshot, +) -> Result, String> { + let sources: Vec<(&CanonicalPath, &syn::File)> = snapshot + .rust() + .parsed_under(&[TOOLING_DIRECTORY])? + .into_iter() + .filter(|(path, _)| path.as_str() != PROOF_SURFACE) + .collect(); + if sources.is_empty() { 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" + "{TOOLING_DIRECTORY}/ carries no Rust source: 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) } @@ -914,8 +908,34 @@ fn services_sources(root: &Path) -> Result, String> { /// it found rather than what it hoped for. #[cfg(test)] mod tests { - use super::{mint_verdict, services_sources}; - use crate::repository::walk::repo_root; + use super::{MintVerdict, mint_verdict as verdict_of_trees, services_sources}; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; + + /// 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 mint_verdict(sources: &[(String, String)]) -> MintVerdict { + 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 seam that puts a name into the refused population, which is what /// makes a closed record a refusal body. @@ -1366,27 +1386,19 @@ mod tests { /// 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)); + fn the_real_subsystem_mints_every_body_from_inside() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = services_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + assert!(verdict.offenders.is_empty(), "{verdict:?}"); assert!( - read.is_ok(), - "the mint gate could not read its subject: {read:?}" + verdict.bodies > 0, + "no closed refusal body found in the real subsystem: {verdict:?}" ); 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), + verdict.roads >= verdict.bodies, "a closed refusal body in the real subsystem is produced by no road at all" ); + Ok(()) } } diff --git a/xtask/src/checks/obligations.rs b/xtask/src/checks/obligations.rs index 826958a..98920ee 100644 --- a/xtask/src/checks/obligations.rs +++ b/xtask/src/checks/obligations.rs @@ -9,14 +9,15 @@ //! 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::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 +175,27 @@ 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}"))?; - let existing = declared_laws(&laws); +pub(crate) fn check_obligations_join(snapshot: &RepositorySnapshot) -> Result<(), String> { + let laws = snapshot.files().text(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"))?; + let unrecognized = document.unrecognized_data_blocks(); + if unrecognized > 0 { + offenders.push(format!( + "{spelled}: {unrecognized} fenced data block(s) declare no schema this repository \ + reads, so whatever they carry is joined by nothing and counted by nothing" + )); + } + let declared = home_rows(&ledger, &spelled); offenders.extend(declared.offences); claimed.extend(declared.claimed); routes.extend(declared.routes); @@ -196,7 +204,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 +212,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 +241,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 +291,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 +303,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 => (), } @@ -367,52 +405,6 @@ 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 @@ -744,19 +736,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 +803,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 +930,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 +976,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 +1424,99 @@ 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::snapshot::{RepositorySnapshot, repository_snapshot}; + use crate::repository::types::{CanonicalPath, GreenRow, ObligationRecord, Read}; + + /// 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 +1549,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 +1631,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 +1681,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 +1692,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 +1796,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 +1824,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 +1867,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 +1934,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 +1960,7 @@ mod tests { "{fixture} stood as a green positive control: {offered:?}" ); } + Ok(()) } /// Planted reversal: a green route spelled loosely rather than exactly — @@ -2876,28 +2916,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 +2963,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 +2979,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 +3023,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 +3098,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 +3192,15 @@ 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(()) } /// Planted reversal: an obligation claiming a law nobody wrote, spelled the @@ -3257,11 +3298,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,6 +3320,7 @@ 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 @@ -3420,43 +3461,42 @@ mod tests { /// 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 +3509,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 +3541,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 +3779,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 +3803,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 @@ -3770,11 +3818,14 @@ mod tests { /// 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. #[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(); - let existing = declared_laws(&laws); + fn the_real_seats_are_the_real_laws() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let claimed = real_claims(snapshot)?; + let laws = snapshot + .files() + .text(super::PROOF_SURFACE) + .taken(super::PROOF_SURFACE)?; + let existing = declared_laws(laws); assert!(!existing.is_empty(), "laws.rs declares no law"); assert_eq!( claimed.len(), @@ -3785,5 +3836,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..5d7704e 100644 --- a/xtask/src/checks/placement.rs +++ b/xtask/src/checks/placement.rs @@ -8,58 +8,73 @@ //! 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". + +use std::collections::BTreeSet; +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"; -use std::fs; -use std::path::Path; +/// The attribute a band declaration carries. +const PATH_ATTRIBUTE: &str = "path"; -use crate::repository::types::ModuleLayout; -use crate::repository::walk::module_source; +/// The attribute that takes a declaration out of the order it would otherwise +/// stand in. +const CONDITION_ATTRIBUTE: &str = "cfg"; /// 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"); - 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); - } - } - bands.sort(); +pub(crate) fn check_band_map(snapshot: &RepositorySnapshot) -> Result<(), String> { + let bands = band_directories(snapshot); let mut offenders = Vec::new(); 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); + 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 +86,71 @@ 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"]; +/// Every numbered band directory the machine's tree carries, in ascending band +/// order. +/// +/// A band is a directory whose name opens with two digits and an underscore. +/// The set is derived from the reading rather than from a list anybody +/// maintains. +fn band_directories(snapshot: &RepositorySnapshot) -> Vec { + let mut bands = BTreeSet::new(); + for (path, _) in snapshot.files().under(MACHINE_DIRECTORY) { + let Some(tail) = path + .as_str() + .get(MACHINE_DIRECTORY.len().saturating_add(1)..) + else { + continue; + }; + let Some((head, _)) = tail.split_once('/') else { + continue; + }; + let Some((number, _)) = head.split_once('_') else { + continue; + }; + if number.len() == 2 && number.chars().all(|digit| digit.is_ascii_digit()) { + bands.insert(head.to_owned()); + } + } + bands.into_iter().collect() +} + +/// The band directories one crate root declares, in declaration order. +/// +/// 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) -> Vec { + root.items.iter().filter_map(declared_band).collect() +} + +/// The band directory one declared item names, where it names one. +fn declared_band(item: &syn::Item) -> Option { + let syn::Item::Mod(module) = item else { + return None; + }; + module.attrs.iter().find_map(|attribute| { + let stated = string_attribute(attribute, PATH_ATTRIBUTE)?; + let (directory, _) = stated.split_once('/')?; + Some(directory.to_owned()) + }) +} + +/// 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 +167,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 +183,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 @@ -118,21 +198,20 @@ const TOOLING_MODULE_ROOT: [&str; 3] = ["macros", "macroc", "src"]; /// 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); +pub(crate) fn check_tooling_module_order(snapshot: &RepositorySnapshot) -> Result<(), String> { + let root_path = format!("{TOOLING_SOURCE}/lib.rs"); + let root = snapshot + .rust() + .source(&CanonicalPath::spelled(&root_path)) + .taken(&root_path)?; + let order = declared_module_order(root); if order.is_empty() { - return Err(format!("{} declares no modules", lib_path.display())); + return Err(format!("{root_path} declares no modules")); } 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); if violations.is_empty() { @@ -142,139 +221,236 @@ pub(crate) fn check_tooling_module_order(root: &Path) -> Result<(), String> { } } -/// The module names one `lib.rs` declares, in declaration order. +/// The module names one crate root declares, in declaration order. /// /// 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("//") { - continue; - } - if line == "#[cfg(test)]" { - test_only = true; - continue; - } - 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()); +/// in the order exactly as a public one does. A declaration carrying a build +/// CONDITION does not: the proof surface is outside the order by construction. +fn declared_module_order(root: &syn::File) -> Vec { + root.items + .iter() + .filter_map(|item| { + let syn::Item::Mod(module) = item else { + return None; + }; + if module + .attrs + .iter() + .any(|attribute| attribute.path().is_ident(CONDITION_ATTRIBUTE)) + { + return None; } - test_only = false; + Some(module.ident.to_string()) + }) + .collect() +} + +/// 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}.rs"); + if snapshot.files().get(&flat).is_some() { + let text = snapshot.files().text(&flat).taken(&flat)?; + return Ok((references_of(text, ModuleLayout::Flat)?, ModuleLayout::Flat)); + } + let directory = format!("{TOOLING_SOURCE}/{name}"); + let mut found = Vec::new(); + let mut carried = false; + for (path, _) in snapshot.files().under(&directory) { + if !path.extension_is("rs") { continue; } - test_only = false; + carried = true; + let text = snapshot.files().text(path.as_str()).taken(path.as_str())?; + found.extend(references_of(text, ModuleLayout::Directory)?); + } + if carried { + Ok((found, ModuleLayout::Directory)) + } else { + 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)); + 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; } - depth = depth.saturating_sub(1); - if depth == 0 { - break; - } - } - ',' => { - 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 +467,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 +504,85 @@ 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, + check_band_map, check_tooling_module_order, declared_module_order, 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) } - /// The declaration order is read out of the file in file order, not sorted, - /// and the test-only proof surface is excluded from it. + /// 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 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"; assert_eq!( - declared_module_order(lib), + declared_module_order(&root(lib)?), vec![ String::from("plane"), String::from("refusal"), - String::from("helper") + String::from("helper"), + String::from("wrapped"), ] ); + 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 +598,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 +649,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 +681,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 +691,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 +702,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 +710,31 @@ 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(()) } } 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 index 00ba47e..2b7e7aa 100644 --- a/xtask/src/checks/seal.rs +++ b/xtask/src/checks/seal.rs @@ -70,12 +70,10 @@ //! 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}; +use crate::repository::snapshot::{MACHINE_DIRECTORY, RepositorySnapshot, TOOLING_DIRECTORY}; +use crate::repository::types::CanonicalPath; /// The proof surfaces, excluded from the population by name. /// @@ -104,8 +102,10 @@ const MARKER_SEAT: &str = "PhantomData"; /// 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)?; +pub(crate) fn check_stamped_guards_seal_their_position( + snapshot: &RepositorySnapshot, +) -> Result<(), String> { + let sources = seal_sources(snapshot)?; let verdict = seal_verdict(&sources); // The denominator is DERIVED and printed on every run, because a population @@ -201,13 +201,15 @@ struct Reading { unparsable: Vec, } -/// Reads the stamp, its invocations, and every implementation out of source -/// text, and judges each stamped guard. +/// Reads the stamp, its invocations, and every implementation out of parsed +/// trees, 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 { +/// 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 seal_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> SealVerdict { let reading = read_sources(sources); let mut verdict = SealVerdict { stamped: reading.stamped.len(), @@ -385,18 +387,12 @@ fn empty_reading() -> Reading { } } -/// Parses every source and reads the stamp, its invocations, and the -/// implementations out of the trees. -fn read_sources(sources: &[(String, String)]) -> Reading { +/// Reads the stamp, its invocations, and the implementations out of the parsed +/// trees. +fn read_sources(sources: &[(&CanonicalPath, &syn::File)]) -> 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}" - )), - } + for (path, file) in sources { + read_module(path.as_str(), &file.items, &mut reading); } reading } @@ -758,29 +754,20 @@ fn stand_in(name: &str) -> String { } } -/// 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) +/// 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. +fn seal_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 seal, and the real repository judged by it. @@ -791,8 +778,34 @@ fn seal_sources(root: &Path) -> Result, String> { /// 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; + use super::{SealVerdict, seal_sources, seal_verdict as verdict_of_trees}; + use crate::repository::snapshot::repository_snapshot; + use crate::repository::types::CanonicalPath; + + /// 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 seal_verdict(sources: &[(String, String)]) -> SealVerdict { + 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 stamp as the machine writes it: one road in, one comparison that /// reads the seat from inside, and no road out. @@ -1109,27 +1122,19 @@ crate::scope_guard_version! { /// /// 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:?}" - ); + fn the_real_tree_seals_every_stamped_guard() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let sources = seal_sources(snapshot)?; + let verdict = verdict_of_trees(&sources); + assert!(verdict.offenders.is_empty(), "{verdict:?}"); assert!( - read.as_ref().is_ok_and(|verdict| verdict.stamped > 0), - "no stamped scope guard found in the real tree: {read:?}" + verdict.stamped > 0, + "no stamped scope guard found in the real tree: {verdict:?}" ); - assert!( - read.is_ok_and(|verdict| verdict.sealed == verdict.stamped), + assert_eq!( + verdict.sealed, verdict.stamped, "the real tree stamps a guard whose position has a road out" ); + 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..5047dc4 100644 --- a/xtask/src/checks/toolchain.rs +++ b/xtask/src/checks/toolchain.rs @@ -6,12 +6,28 @@ //! 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; + +/// 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"; /// Every statement of the toolchain floor names the same version. /// @@ -23,87 +39,93 @@ 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]") { - return Err(String::from( - "root Cargo.toml has no [workspace.lints.rust] wall", +/// +/// 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. +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 members = bracket_list(&manifest, "members")?; + let members = strings_at(manifest, &["workspace", "members"]).taken("the workspace 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") { + let path = format!("{member}/{MANIFEST_FILE}"); + let document = snapshot.cargo().document(&path).taken(&path)?; + if declares_yes(document, &["lints", "workspace"]).known() != Some(&Declaration::Yes) { missing.push(member); } } @@ -119,7 +141,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 +153,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 +162,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 +175,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 +185,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 +220,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 +230,43 @@ 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: 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..dc9af34 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; @@ -31,6 +31,7 @@ mod repository; mod qualification; use std::error::Error; +use std::fmt; use std::path::Path; use crate::checks::coupling::check_collection_bodies_are_coupled; @@ -46,14 +47,18 @@ use crate::checks::seal::check_stamped_guards_seal_their_position; 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::types::Check; -use crate::repository::walk::repo_root; +use crate::repository::snapshot::{RepositorySnapshot, repo_root}; +use crate::repository::types::{Check, Read}; + +/// 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,8 +66,24 @@ 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 the commit those files were committed at — +/// 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. fn run_checks(root: &Path) -> Result<(), Box> { + let snapshot = RepositorySnapshot::read(root)?; + println!( + "read {} files at commit {} (committed tree {})", + snapshot.files().count(), + spelled(snapshot.commit()), + spelled(snapshot.tree()) + ); let checks: [Check; 17] = [ ("agents-claude-parity", check_agents_claude_parity), ("lf-and-no-symlinks", check_lf_and_no_symlinks), @@ -99,7 +120,7 @@ fn run_checks(root: &Path) -> Result<(), Box> { ]; 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}"); @@ -114,3 +135,15 @@ fn run_checks(root: &Path) -> Result<(), Box> { Err(format!("{} repository law(s) broken", failures.len()).into()) } } + +/// How one read fact is spelled in the line a run opens with. +/// +/// An unknown says it is unknown. A run that printed a blank where a commit +/// belongs would be a run claiming to have judged something it cannot name. +fn spelled(read: &Read) -> String { + match *read { + Read::Known(ref fact) => fact.to_string(), + Read::DeclaredAbsent(reason) => format!("unknown ({reason})"), + Read::Unreadable(ref failure) => format!("unknown ({failure})"), + } +} 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..7ad7dce --- /dev/null +++ b/xtask/src/repository/cargo.rs @@ -0,0 +1,809 @@ +//! 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 RESOLVES** is a different question, and only cargo answers it: +//! `cargo metadata --locked --format-version 1` reports package identities, edge +//! kinds, renames, target-conditioned edges, and the graph itself. 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. +//! +//! # 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 resolved, or why nobody asked it. + resolved: 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 { + resolved: resolve(root, files), + documents, + census, + } + } + + /// What cargo resolved, or why nobody asked it. + pub(crate) const fn resolved(&self) -> &Read { + &self.resolved + } + + /// 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, +} + +/// 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. +/// +/// The fields are exactly the ones a law reads. `cargo metadata` reports a great +/// deal more, 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 ResolvedWorkspace { + /// Every package in the resolved graph, workspace members included. + packages: Vec, +} + +impl ResolvedWorkspace { + /// The package cargo resolved under one name, or the declared absence of + /// it. + pub(crate) fn package(&self, named: &str) -> Read<&ResolvedPackage> { + match self.packages.iter().find(|package| package.name == named) { + Some(found) => Read::Known(found), + None => Read::DeclaredAbsent(AbsenceReason::NoSuchKey), + } + } +} + +/// One package as cargo resolved it. +#[derive(Debug, Deserialize)] +pub(crate) struct ResolvedPackage { + /// The package name, which is the identity a law judges. + name: String, + /// Every edge cargo resolved out of it, of every kind. + dependencies: Vec, +} + +impl ResolvedPackage { + /// Every edge cargo resolved out of this package. + pub(crate) fn dependencies(&self) -> &[ResolvedDependency] { + &self.dependencies + } +} + +/// One resolved edge, as cargo reports it. +#[derive(Debug, Deserialize)] +pub(crate) struct ResolvedDependency { + /// The PACKAGE the edge reaches. Cargo reports the package rather than the + /// key, so a rename is already resolved 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 edge is conditioned on, where it is + /// conditioned at all. + target: Option, +} + +impl ResolvedDependency { + /// The package the edge reaches. + 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 edge is conditioned. + pub(crate) fn target(&self) -> Option<&str> { + self.target.as_deref() + } +} + +/// Asks cargo what it resolved, 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 resolution: an empty resolution would +/// answer "the core reaches no tooling" about a tree cargo never opened. +fn resolve(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(resolved) => Read::Known(resolved), + 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 5e191dd..0000000 --- a/xtask/src/repository/manifest.rs +++ /dev/null @@ -1,355 +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, and that is two ceilings, each with a direction -//! -//! Every reader here is line-oriented, which is exact for the manifests this -//! repository commits and for every spelling named above, and which cannot see -//! two constructs. -//! -//! **A multi-line basic string whose body reads like a manifest.** Its lines -//! are read as the header and entries they resemble, so a table quoted inside -//! a `description` is read as a table. That answer is wrong in the direction -//! this law can afford — a lawful manifest is REFUSED, never a prohibited one -//! passed — and the reversal that would run the other way does not survive -//! cargo. MEASURED on cargo 1.97.1: a decoy entry in a dependency table whose -//! value is a multi-line string, which is the one shape that could close a -//! table early and hide the edge beneath it, fails with `failed to parse the -//! version requirement`, so it never reaches a build. -//! -//! **A dependency table written as an INLINE table**, whose entries live -//! inside one line's value rather than on lines of their own. This one could -//! hide an edge, so it is not left to be missed: [`dependency_declarations`] -//! reports it by name in [`ManifestDependencies::unread`] and the topology law -//! refuses a manifest that carries one. Reading it properly is the typed -//! repository model's migration, and a second parser seated here would be the -//! duplicate authority this repository is eliminating. - -/// 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); - -/// What one manifest declares about its dependencies: the entries a reader -/// resolved, and the tables 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 dependency table the manifest writes as an inline table, spelled - /// as the key path it sits at. Its entries live inside a value this - /// line-oriented reader does not enter, so they are 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. -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(); - let Some((kind, name, fields)) = dependency_position(&place) else { - if value.starts_with('{') - && let Some(spelling) = unenterable_table(&place) - { - unread.push(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; - } -} - -/// 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..b415005 --- /dev/null +++ b/xtask/src/repository/markdown.rs @@ -0,0 +1,1056 @@ +//! 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. + pub(crate) fn block(&self, schema: BlockSchema) -> Read<&DataBlock> { + let mut declaring = self.blocks.iter().filter(|block| block.schema == schema); + let Some(found) = declaring.next() else { + return Read::DeclaredAbsent(AbsenceReason::NoBlockDeclaresThisSchema); + }; + if declaring.next().is_some() { + 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", + )); + } + Read::Known(found) + } + + /// Every data-language block whose schema this repository does not + /// recognize. + /// + /// Reported rather than skipped. A block written in the data language that + /// declares no schema is a block no reading is about, and a ledger that + /// silently stopped being read is the exact failure this model was built to + /// end. + pub(crate) fn unrecognized_data_blocks(&self) -> usize { + self.blocks + .iter() + .filter(|block| block.schema == BlockSchema::UnrecognizedData) + .count() + } +} + +/// Closes the block the parser just ended, carrying its declared schema with +/// it. +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 { + schema: BlockSchema::declared_by(&language, &carried), + body: carried, + }); +} + +/// Which schema one fenced block declares. +/// +/// Identity is taken from the keys the 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, + /// A data-language block declaring no schema this repository reads. + UnrecognizedData, + /// A fenced block that is not written in the data language at all — a + /// diagram, a shell transcript, a Rust example. + NotData, +} + +impl BlockSchema { + /// The schema a block declares, read off the keys it writes at its own + /// document level. + fn declared_by(language: &str, body: &str) -> Self { + if language != DATA_LANGUAGE { + return BlockSchema::NotData; + } + let keys = document_keys(body); + let declares = |key: &str| keys.iter().any(|written| written == key); + if declares("obligations") { + BlockSchema::ObligationLedger + } else if declares("tooling-obligation") { + BlockSchema::ToolingObligationLedger + } else if declares("phase") { + BlockSchema::PhaseDeclaration + } else if declares("seat") && declares("state") { + BlockSchema::SeatReservation + } else { + BlockSchema::UnrecognizedData + } + } + + /// 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", + BlockSchema::UnrecognizedData => "a data block declaring no known schema", + BlockSchema::NotData => "a block that is not data", + } + } +} + +/// 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: the schema it declares and the text it carries. +pub(crate) struct DataBlock { + /// What the block declares itself to be. + schema: BlockSchema, + /// 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 COUNTED 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_counted() { + let document = MarkdownDocument::parse("```yaml\nsomething: else\nentirely: true\n```\n"); + assert_eq!(document.unrecognized_data_blocks(), 1); + assert!(matches!( + document.block(BlockSchema::ObligationLedger), + 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..53dfa4b 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 resolves, [`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..9c5297c --- /dev/null +++ b/xtask/src/repository/rust.rs @@ -0,0 +1,104 @@ +//! 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) + } +} diff --git a/xtask/src/repository/snapshot.rs b/xtask/src/repository/snapshot.rs new file mode 100644 index 0000000..86d0136 --- /dev/null +++ b/xtask/src/repository/snapshot.rs @@ -0,0 +1,473 @@ +//! 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::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}; + +/// Directories the reading never enters. +/// +/// `.git` is git's own storage and `target` is the build's; neither is +/// repository material, and both are large enough that walking them would make +/// every run pay for bytes no law is about. +const UNREAD_DIRECTORIES: [&str; 2] = [".git", "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 git says the reading was taken at. + git: GitSnapshot, +} + +impl RepositorySnapshot { + /// Reads the repository at one root. + /// + /// The order is the dependency order of the readings: the files first, + /// because every other reading is over them; then the three decoders, each + /// authoritative for its own language; then git, which names what was read. + pub(crate) fn read(root: &Path) -> Result { + let files = CanonicalFileMap::read(root)?; + let cargo = CargoSnapshot::read(root, &files); + let rust = RustSyntaxSnapshot::read(&files); + let markdown = MarkdownSnapshot::read(&files); + let git = GitSnapshot::read(root); + Ok(Self { + files, + cargo, + rust, + markdown, + git, + }) + } + + /// 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 + } + + /// The commit this reading was taken at. + pub(crate) const fn commit(&self) -> &Read { + &self.git.commit + } + + /// The tree that commit names. + pub(crate) const fn tree(&self) -> &Read { + &self.git.tree + } +} + +/// 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, skipping [`UNREAD_DIRECTORIES`]. + /// + /// 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. +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)); + for (name, path, is_directory) in found { + let named = name.to_string_lossy().into_owned(); + let spelled = if inside.is_empty() { + named.clone() + } else { + format!("{inside}/{named}") + }; + if is_directory { + if !UNREAD_DIRECTORIES.contains(&named.as_str()) { + read_directory(&path, &spelled, into)?; + } + continue; + } + into.insert(CanonicalPath::spelled(&spelled), read_file(&path)); + } + Ok(()) +} + +/// 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 } +} + +/// What git says the reading was taken at. +struct GitSnapshot { + /// The commit `HEAD` names. + commit: Read, + /// The tree that commit names. + tree: Read, +} + +impl GitSnapshot { + /// Asks git what `HEAD` names, or states that this root is not a checkout. + /// + /// It names the COMMITTED state the run started from, and deliberately not + /// the bytes that were read: the files map is what was read. Naming both is + /// what lets a log say which tree a green verdict was about — a campaign has + /// already produced one false green from a restore that preserved a + /// modification time, and a run that prints the commit it judged is a run + /// that cannot be confused with a different one. + fn read(root: &Path) -> Self { + if !root.join(".git").exists() { + return Self { + commit: Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout), + tree: Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout), + }; + } + Self { + commit: revision(root, "HEAD").map(CommitId), + tree: revision(root, "HEAD^{tree}").map(TreeId), + } + } +} + +/// 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 super::repository_snapshot; + use crate::repository::types::Read; + + /// The reading names what it read. + /// + /// A run that cannot say which commit it judged is a run whose green cannot + /// be attached to a tree, and this campaign has already produced one false + /// green from a restore that preserved a modification time. + #[test] + fn the_reading_names_the_commit_it_was_taken_at() -> Result<(), String> { + let snapshot = repository_snapshot()?; + let commit = snapshot.commit().required("the commit HEAD names")?; + assert_eq!(commit.to_string().len(), 40, "{commit}"); + let tree = snapshot.tree().required("the tree HEAD names")?; + assert_eq!(tree.to_string().len(), 40, "{tree}"); + 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..49536ee 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 + /// resolve and no resolution 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 resolves 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)) -} From b16e5c61e99a1342b3902724f5f3e8b0cf9d95ed Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 09:17:43 -0400 Subject: [PATCH 5/9] Plant a defect no warm runner can outrun, and delete a deleted reader's instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT REMAINS UNPROVEN. No hosted run stands at this head for either alarm. The second harness's new reversal is executed on a working machine only, both directions, and those transcripts are the whole of its evidence; the hosted seat is where it must be watched next, because the defect it replaces is one only a hosted runner exposed. The mutation alarm is owed a run outright: it carries no trigger a commit can pull, its job condition and its counting guard were READ here and are correct, and reading is not running — `gh workflow run mutation.yml --ref fx-tools` is the command that discharges it. Nothing here claims a hosted step is still wired: `alarm-artifacts-are-present-and-distinct` still fails open on a workflow somebody edited every step out of, and its opening condition — a hosted run publishing the roster of what it EXECUTED — is unchanged and unmet. DENOMINATORS, before and after. The second harness executes 533 tests, before and after; the entry bar executes 533 harness tests and 3 doctests, 536, before and after. Both were written as 532 and 535 and were already stale by one at this head — re-measured here, with the two executed sets compared name for name and the difference empty in both directions. The root package alone runs 183, not the 180 the workflow claimed. One test is ADDED and is executed by neither reading: `the_nextest_timeout_reversal` carries `#[ignore]`, both harnesses report it skipped, and the reversal step is the one run that selects it. Repository laws 18, red twins 21 discharged / 179 owed, tooling reversals 18 / 3, collection bodies 27 / 27, seat modules 7 / 7, inhabitant-promising limits 8 / 8, manifest census 19 entries — all unmoved. THE ARTIFACT. The second harness's planted reversal is no longer a timing race. It selected `binary(compile_refusals)` 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 the same job pays it first. The hosted run at f25f33d is what said so: nextest exited 0 under the reversal profile, the step requiring a refusal went red, and the newly added alarm's own negative control was the only red on the board. What stands there now is a dedicated test whose entire body sleeps thirty seconds, selected by `test(the_nextest_timeout_reversal)` and killed after one. A sleep has no cache, so the same thing happens cold, warm, and busy. The subject carries `#[ignore]`, which is what keeps it out of `cargo test`, the entry bar's `tests` stage, the harness's own positive step, and every mutation rebuild. Two flags reach past that and both are MEASURED absences of a configuration key rather than preferences: `run-ignored` and `no-tests` are not profile keys on 0.9.132 — each prints `ignoring unknown configuration key` and changes nothing — so the step spells `--run-ignored only` and `--no-tests fail`. `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 reds the step. The step now requires THREE things, because no two are enough — exit 100, the `timed out` diagnostic, and the subject's own name in the transcript, since a code with a diagnostic still accepts a run that timed something else out. The root manifest's paragraph forcing the workspace member array onto one line is DELETED, and the readable array is back. It described `no-core-tooling-edge` reading manifests a line at a time; this branch deleted that reader in `xtask/src/repository/cargo.rs`, which now decodes with `toml` plus `cargo metadata --locked --format-version 1`, and the paragraph came back with the merge at f25f33d — the mechanism's operating instructions resurrected without the mechanism. It arrived in 6187cb1 on the other lane, and the branch tip before the merge, 570c31b, carried neither the paragraph nor the one-line array. FOUND AND NOT REPAIRED, named rather than widened into the diff. The sibling sweep for prose about readers that no longer exist turns up only PAST-TENSE records — `xtask/Cargo.toml`, `xtask/src/repository/cargo.rs`, `xtask/src/repository/markdown.rs`, `xtask/src/checks/{placement,dependency}.rs` each say what a line reader DID and that it was replaced, which is the hand-over being written down rather than a live instruction. Three of those files belong to lanes running in parallel and are untouched here either way. The mutation workflow's counting guard reads its four rosters with `wc -l`, so a report file whose last entry carries no trailing newline would be undercounted by one; the guard's refusals at zero examined and zero caught are unaffected, and it is left for the run that is owed to expose or dismiss. And one the mutation alarm's own prose implies is already dealt with, which it is not: `dependencies.yml` still conditions its `graph` job as `github.event_name != 'schedule'`. That is the skip-list shape `mutation.yml` line 47 cites as the lesson — the repair landed on the `advisory` job at line 155 and never reached `graph`, whose comment now presents the absorption as a convenience ("the push trigger above needed nothing added here to reach this job"). It already absorbs `workflow_dispatch` and will absorb the next trigger the file grows. It is inside this lane's files and outside its three tasks, so it is named here rather than repaired into this diff; the repair is a positive list of the three events that job is actually for. Co-Authored-By: Claude Opus 5 (1M context) --- .config/nextest.toml | 140 +++++++++++++++++++++++----------- .github/workflows/harness.yml | 48 +++++++++--- Cargo.toml | 19 ++--- xtask/src/checks/alarms.rs | 64 ++++++++++++++-- 4 files changed, 198 insertions(+), 73 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 94bcfef..187160f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -19,15 +19,22 @@ # 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 532 harness tests and 3 +# - `cargo test --locked --workspace` executes 533 harness tests and 3 # doctests, and passes; -# - `cargo nextest run --locked --workspace` executes 532, 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 @@ -39,9 +46,9 @@ # 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 535 on both hosts, and the doctests +# 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 — 532 of 535 — and the harness job deliberately runs no +# 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 @@ -107,14 +114,19 @@ retries = 0 # 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`. `compile_refusals` shells out to -# rustc once per compile-fail fixture and MEASURED takes 17 seconds on a warm -# working machine — a cold hosted runner with no cache, which is what this -# repository deliberately runs, is slower by a factor nobody here has measured. -# 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 the day a hosted run publishes -# per-test durations to set it against, and not before. +# 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. @@ -141,43 +153,83 @@ failure-output = "immediate-final" # found, the reversal stops refusing and the step that requires a refusal turns # red. One artifact, both facts. # -# WHAT IS PLANTED, and it is deliberately NOT a timing race. The profile selects -# the one test in this repository that is known to be slow — `compile_refusals` -# shells out to rustc once per compile-fail fixture and MEASURED takes 17 seconds -# on a warm working machine — and then sets a kill threshold of one second -# against it. The margin is a factor of seventeen on the fastest host anybody has -# run this on, and a cold hosted runner only widens it. Both halves are stated -# HERE rather than in the step that runs them, so the reversal is one artifact -# and a run of it needs no arguments to be the reversal. -# -# MEASURED, on the pinned toolchain: `cargo nextest run --locked --workspace -# --profile reversal` reports `1 timed out`, prints `TIMEOUT` against the test, -# and exits 100. The step requires BOTH, for the reason the dependency gate's -# reversal step requires both: nextest exits non-zero for a usage error and for -# a configuration it could not read as well, so an exit code alone would accept -# a run that never reached a test. Exit 100 with `timed out` is the one pair -# that means it refused for the reason that was planted. -# -# THE ONE WAY THIS REVERSAL CAN STOP BEING ABOUT ANYTHING is `compile_refusals` -# becoming fast, or leaving. It does not go silent when that happens: the step -# requires a REFUSAL, so a run that suddenly passes turns the job red rather than -# green, and the day the compile-refusal harness is retired is a day this profile -# is rewritten on purpose. +# 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, 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. +# 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 = 'binary(compile_refusals)' +default-filter = 'test(the_nextest_timeout_reversal)' slow-timeout = { period = "1s", terminate-after = 1 } diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index a1265bd..0e2cd43 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -81,13 +81,18 @@ jobs: # 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 — 180 tests rather than 532 — and both runs print the same shape of + # 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 @@ -101,7 +106,7 @@ jobs: # not, and this repository does not seat a claim twice. # # So this job's population is a STRICT SUBSET of the bar's, deliberately: - # 532 of the 535 the bar executes. The three it does not reach are named in + # 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. @@ -112,12 +117,28 @@ jobs: # `.config/nextest.toml`'s `reversal` profile is deliberately wrong, and # this step fails the job when nextest SUCCEEDS under it. # - # BOTH the exit code and the diagnostic are required, because neither is - # enough alone. nextest exits non-zero for a usage error and for a - # configuration it could not read as well, so the code by itself would - # accept a run that never reached a test. MEASURED on the pinned - # toolchain: exit 100 with `timed out` is the one pair that means it - # refused for the reason that was planted. + # 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 @@ -125,7 +146,7 @@ jobs: - name: The planted reversal must refuse run: | set +e - transcript=$(cargo nextest run --locked --workspace --profile reversal 2>&1) + transcript=$(cargo nextest run --locked --workspace --profile reversal --run-ignored only --no-tests fail 2>&1) code=$? set -e printf '%s\n' "$transcript" @@ -140,4 +161,11 @@ jobs: exit 1 ;; esac - echo "the planted reversal refused: exit 100, timed out" + 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/Cargo.toml b/Cargo.toml index d00ade4..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" diff --git a/xtask/src/checks/alarms.rs b/xtask/src/checks/alarms.rs index 7e71fe7..25b7a8a 100644 --- a/xtask/src/checks/alarms.rs +++ b/xtask/src/checks/alarms.rs @@ -70,10 +70,11 @@ //! 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 the -//! one-second kill threshold against a seventeen-second test it was written for -//! is established by the harness job's own requirement of exit 100 AND a timed-out -//! test, and that requirement stands exactly as long as that step does. +//! 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, @@ -241,12 +242,18 @@ fn spelled(segments: &[&str]) -> String { segments.join("/") } -/// Planted reversals for a law whose subject is the tree. +/// 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. The last two tests read the ceiling and the -/// real tree, and each states what it found. +/// 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::{ @@ -263,7 +270,7 @@ mod tests { fail-fast = false\n\ retries = 0\n\n\ [profile.reversal]\n\ - default-filter = 'binary(compile_refusals)'\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"; @@ -468,4 +475,45 @@ mod tests { 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)); + } } From 0dc315ae788a3e17cd069b8737ce02c990e29a1b Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 09:26:34 -0400 Subject: [PATCH 6/9] Delete the last line scanner, and stop the reading claiming what it never bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five repairs to the repository reading, each with an executed reversal. THE LAST LINE SCANNER. `obligations.rs::declared_laws` still read `src/laws.rs` as text: a raw `mod ` prefix, a previous line exactly `#[test]`, a current line opening `fn `. Every valid Rust shape that breaks that grammar vanished from the join's denominator while cargo went on executing it — `#[test] #[should_panic]`, `#[test]` written after a `#[cfg]`, an `#[expect]`, or a documentation comment, and anything inside a nested inline module, which the reader attributed to the module above it. It also read laws that do not exist: `#[test]` on its own line inside a string literal opened a phantom. The reading is now `RustSyntaxSnapshot::functions_in` — the one parse, module path built on the way down, complete attribute set — filtered by the same `is_the_harness_attribute` roster the testpak seat population uses. `laws.rs` and `testpak/tests/` are asked one question by one reader. THE WALKER'S POPULATION MOVED UNDER PATH SHAPE, three ways. The two exclusions were basename matches at every depth, so `src//target/`, `docs/target/` and `testpak/target/` would have disappeared with no law reporting the absence; they are root-relative now. `.git` is a DIRECTORY in a clone and a FILE in a worktree, and only the directory was skipped — so the same committed tree read as two different populations depending on checkout topology. It was live: this worktree's `.git` file was in the canonical file map. And `to_string_lossy` was building path identities, so two non-Unicode names collapse onto one key and one insertion silently overwrites the other. A lossy rendering is not an identity; the reading refuses such a name and says why it refuses rather than keying on platform bytes — every CanonicalPath is a join key against UTF-8 text somebody wrote, so a name no row can spell is a name no join can resolve. THE PRINTED COMMIT WAS NOT BOUND TO THE BYTES. The walk ran, git was asked afterwards, and the run printed `read N files at commit X` — a relationship nothing had checked. On a dirty tree that sentence was simply false, and `check` alone never noticed. Git is asked before the walk and again after it, the checkout is asked what differs, and the reading now carries a CommitBinding: either the committed tree these bytes ARE, or what stops them from being one. A commit that moved between the two readings refuses the whole reading. The opening sentence names no commit it did not bind — which also settles the `unknown (...)` case, where the run used to state it had no commit and claim a commit-bound result in the same line. CARGO METADATA WAS DESCRIBED ONE LEVEL TOO STRONGLY. `packages[].dependencies` is each package's dependency DECLARATIONS as cargo normalized them, not the resolved graph, which is `resolve.nodes[].deps`. The normalized declarations are the right subject for the topology rule — a target-conditional or currently inactive edge must still be forbidden — so the repair is vocabulary, at every site: ResolvedWorkspace/ResolvedPackage/ResolvedDependency become NormalizedWorkspace/NormalizedPackage/NormalizedDependency, `resolved()` becomes `normalized()`, `dependencies()` becomes `declarations()`. No behaviour moved. MARKDOWN SCHEMA SELECTION WAS PRIORITY-ORDERED. A block declaring both `obligations:` and `phase:` was an obligation ledger because that arm was written first, and the phase reader further down the file found nothing. Every schema is calculated now and exactly one is required; two is an offence naming both, refused for BOTH readers rather than won by one. BlockSchema shrank to the four real schemas and a separate BlockReading carries "exactly one, none, or many", because "which schema" and "whether there is one" are two questions. OBLIGATION RECORD IDENTITY is implemented here rather than deferred: an id that is empty, and two records in one home sharing one, are both offences. B2 would not be complete without it — the id is what a record's 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 shared one lets one marker discharge two claims. DENOMINATORS, before and after: files read 311 -> 310, and that ONE file is the worktree's `.git`, which is now excluded; `git ls-files` reports 310. Repository laws 183 -> 183, every pair identical, and the number is pinned in `the_real_seats_are_the_real_laws` because a reader was replaced. Red twins (core) 21/179, tooling reversals 18/3, collection bodies 27/27, seat modules 7/7, inhabitant-promising limits 8/8, manifest census 19 — all unmoved. Outside the two owned files, named rather than widened: `main.rs` prints the binding instead of a commit it had not established, and `checks/dependency.rs` carries the cargo-metadata vocabulary at its own sites. Both are the same repair reaching its other end; neither belongs to a parallel agent's surface. --- xtask/src/checks/dependency.rs | 81 +++-- xtask/src/checks/obligations.rs | 370 +++++++++++++++++--- xtask/src/main.rs | 29 +- xtask/src/repository/cargo.rs | 116 +++--- xtask/src/repository/markdown.rs | 277 ++++++++++++--- xtask/src/repository/mod.rs | 8 +- xtask/src/repository/rust.rs | 105 ++++++ xtask/src/repository/snapshot.rs | 582 +++++++++++++++++++++++++++---- xtask/src/repository/types.rs | 4 +- 9 files changed, 1307 insertions(+), 265 deletions(-) diff --git a/xtask/src/checks/dependency.rs b/xtask/src/checks/dependency.rs index f65fd36..89e10d0 100644 --- a/xtask/src/checks/dependency.rs +++ b/xtask/src/checks/dependency.rs @@ -12,17 +12,25 @@ //! spelling arrives as one declaration, because the decoder resolved the //! document before this law saw it. //! -//! **What cargo RESOLVES** is a different question, and the manifests cannot -//! answer it: an edge can arrive through workspace inheritance, and a package -//! identity is something only the resolver settles. `cargo metadata` is asked -//! directly. +//! **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. //! -//! Neither 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 resolution that -//! never happened is the exact silence this repository is eliminating. +//! 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::cargo::{DeclaredDependency, MANIFEST_FILE, ResolvedWorkspace}; +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. @@ -64,11 +72,11 @@ const JUDGE_PACKAGE: &str = "threadpak-testpak"; /// consumer fixture at `xtask/fixtures/macro-consumer`. /// /// **Both parts are asked of both authorities.** A declared edge is refused -/// where a manifest writes one; a resolved edge is refused where cargo resolves -/// 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 resolution -/// refuses rather than passes. +/// 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 @@ -84,21 +92,21 @@ pub(crate) fn check_no_core_tooling_edge(snapshot: &RepositorySnapshot) -> Resul .filter_map(judge_services_declaration) .map(|violation| format!("services reach their expansion surface: {violation}")), ); - let resolved = snapshot + let normalized = snapshot .cargo() - .resolved() - .required("what cargo resolved for this workspace")?; - reported.extend(resolved_offences( - resolved, + .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 resolved it", + "core package reaches tooling or its judge, as cargo normalized the manifests", )?); - reported.extend(resolved_offences( - resolved, + reported.extend(normalized_offences( + normalized, SERVICES_PACKAGE, judge_services_package, - "services reach their expansion surface, as cargo resolved it", + "services reach their expansion surface, as cargo normalized the manifests", )?); if reported.is_empty() { Ok(()) @@ -146,24 +154,25 @@ fn judge_services_declaration(entry: &DeclaredDependency) -> Option { None } -/// Every offence one RESOLVED package commits. +/// Every offence one package's NORMALIZED declarations commit. /// -/// The resolved reading judges package IDENTITY and nothing else. A resolved -/// path 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 resolved_offences( - resolved: &ResolvedWorkspace, +/// 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 = resolved + let found = normalized .package(package) - .taken(&format!("the package `{package}` in what cargo resolved"))?; + .taken(&format!("the package `{package}` in what cargo normalized"))?; let mut offences = Vec::new(); - for edge in found.dependencies() { + for edge in found.declarations() { let Some(violation) = judge(edge.package()) else { continue; }; @@ -180,7 +189,7 @@ fn resolved_offences( Ok(offences) } -/// The violation one package the CORE resolved to commits, if any. +/// 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 { Some(format!("resolves to package `{identity}`")) @@ -189,7 +198,7 @@ fn judge_core_package(identity: &str) -> Option { } } -/// The violation one package the SERVICES resolved to commits, if any. +/// The violation one package the SERVICES declare an edge to commits, if any. fn judge_services_package(identity: &str) -> Option { if identity == FRONTEND_PACKAGE { Some(format!("resolves to package `{FRONTEND_PACKAGE}`")) diff --git a/xtask/src/checks/obligations.rs b/xtask/src/checks/obligations.rs index 98920ee..aae07c2 100644 --- a/xtask/src/checks/obligations.rs +++ b/xtask/src/checks/obligations.rs @@ -10,6 +10,7 @@ //! that is stated out loud is a debt somebody can act on. 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}; @@ -176,8 +177,11 @@ const CONTROL_MARKER: &str = "green:"; /// repository that quietly lost red twins would otherwise keep passing this /// check while the accounting shrank. pub(crate) fn check_obligations_join(snapshot: &RepositorySnapshot) -> Result<(), String> { - let laws = snapshot.files().text(PROOF_SURFACE).taken(PROOF_SURFACE)?; - let existing = declared_laws(laws); + 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(); @@ -188,13 +192,12 @@ pub(crate) fn check_obligations_join(snapshot: &RepositorySnapshot) -> Result<() let spelled = home.to_string(); let ledger = obligation_ledger(document, &spelled) .taken(&format!("{spelled}'s obligation ledger"))?; - let unrecognized = document.unrecognized_data_blocks(); - if unrecognized > 0 { - offenders.push(format!( - "{spelled}: {unrecognized} fenced data block(s) declare no schema this repository \ - reads, so whatever they carry is joined by nothing and counted by nothing" - )); - } + 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); @@ -356,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 @@ -382,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!( @@ -405,32 +452,53 @@ fn record_field_offences(records: &[ObligationRecord], readme: &str) -> Vec 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 @@ -1432,9 +1500,21 @@ mod tests { use crate::repository::markdown::{ MarkdownDocument, ObligationLedger, obligation_ledger, tooling_reversal_rows, }; + 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 @@ -3203,6 +3283,102 @@ mod tests { 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 /// four ways a SECOND reader matching `"green: laws.rs "` could not see — /// no space after the colon, two spaces, a tab before `laws.rs`, and a tab @@ -3215,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", @@ -3234,6 +3410,7 @@ mod tests { "{spelled:?}: {found:?}" ); } + Ok(()) } /// Planted reversal: a seat row carrying a token AFTER its target, read end @@ -3249,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!( @@ -3278,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 @@ -3327,8 +3505,8 @@ mod tests { /// 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 @@ -3336,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. @@ -3459,6 +3639,83 @@ 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 reading's own failure mode, refused rather than trusted. This join @@ -3817,16 +4074,33 @@ 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-THREE 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. #[test] fn the_real_seats_are_the_real_laws() -> Result<(), String> { let snapshot = repository_snapshot()?; let claimed = real_claims(snapshot)?; let laws = snapshot - .files() - .text(super::PROOF_SURFACE) + .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"); + let existing = declared_laws(&laws); + assert_eq!( + existing.len(), + 183, + "laws.rs declares {} laws", + existing.len() + ); assert_eq!( claimed.len(), existing.len(), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 50e38c0..bb82562 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -31,7 +31,6 @@ mod repository; mod qualification; use std::error::Error; -use std::fmt; use std::path::Path; use crate::checks::alarms::check_alarm_artifacts; @@ -49,7 +48,7 @@ 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, Read}; +use crate::repository::types::Check; /// The command a bare `cargo xtask` means. const DEFAULT_COMMAND: &str = "check"; @@ -73,17 +72,23 @@ fn main() -> Result<(), Box> { /// 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 the commit those files were committed at — +/// 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 snapshot = RepositorySnapshot::read(root)?; println!( - "read {} files at commit {} (committed tree {})", + "read {} files {}", snapshot.files().count(), - spelled(snapshot.commit()), - spelled(snapshot.tree()) + 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 @@ -152,15 +157,3 @@ fn run_checks(root: &Path) -> Result<(), Box> { Err(format!("{} repository law(s) broken", failures.len()).into()) } } - -/// How one read fact is spelled in the line a run opens with. -/// -/// An unknown says it is unknown. A run that printed a blank where a commit -/// belongs would be a run claiming to have judged something it cannot name. -fn spelled(read: &Read) -> String { - match *read { - Read::Known(ref fact) => fact.to_string(), - Read::DeclaredAbsent(reason) => format!("unknown ({reason})"), - Read::Unreadable(ref failure) => format!("unknown ({failure})"), - } -} diff --git a/xtask/src/repository/cargo.rs b/xtask/src/repository/cargo.rs index 43a832d..b6b659d 100644 --- a/xtask/src/repository/cargo.rs +++ b/xtask/src/repository/cargo.rs @@ -10,12 +10,34 @@ //! 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 RESOLVES** is a different question, and only cargo answers it: -//! `cargo metadata --locked --format-version 1` reports package identities, edge -//! kinds, renames, target-conditioned edges, and the graph itself. 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. +//! **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 //! @@ -50,8 +72,8 @@ const TARGET_TABLE: &str = "target"; /// Everything the Cargo authorities established about this repository, read /// once. pub(crate) struct CargoSnapshot { - /// What cargo resolved, or why nobody asked it. - resolved: Read, + /// 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>, @@ -72,15 +94,15 @@ impl CargoSnapshot { } let census = ManifestCensus::take(&documents); Self { - resolved: resolve(root, files), + normalized: ask_cargo(root, files), documents, census, } } - /// What cargo resolved, or why nobody asked it. - pub(crate) const fn resolved(&self) -> &Read { - &self.resolved + /// 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 @@ -428,22 +450,24 @@ pub(crate) fn declares_table(document: &toml::Table, key_path: &[&str]) -> Read< } } -/// What `cargo metadata` reported. +/// 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, 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. +/// 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 ResolvedWorkspace { - /// Every package in the resolved graph, workspace members included. - packages: Vec, +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 ResolvedWorkspace { - /// The package cargo resolved under one name, or the declared absence of +impl NormalizedWorkspace { + /// The package cargo reported under one name, or the declared absence of /// it. - pub(crate) fn package(&self, named: &str) -> Read<&ResolvedPackage> { + 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), @@ -451,39 +475,45 @@ impl ResolvedWorkspace { } } -/// One package as cargo resolved it. +/// One package's manifest, as cargo normalized it. #[derive(Debug, Deserialize)] -pub(crate) struct ResolvedPackage { +pub(crate) struct NormalizedPackage { /// The package name, which is the identity a law judges. name: String, - /// Every edge cargo resolved out of it, of every kind. - dependencies: Vec, + /// Every dependency this package's manifest DECLARES, of every kind, + /// whether or not a build selects it. + dependencies: Vec, } -impl ResolvedPackage { - /// Every edge cargo resolved out of this package. - pub(crate) fn dependencies(&self) -> &[ResolvedDependency] { +impl NormalizedPackage { + /// Every dependency this package's manifest declares. + pub(crate) fn declarations(&self) -> &[NormalizedDependency] { &self.dependencies } } -/// One resolved edge, as cargo reports it. +/// 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 ResolvedDependency { - /// The PACKAGE the edge reaches. Cargo reports the package rather than the - /// key, so a rename is already resolved here. +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 edge is conditioned on, where it is + /// The platform predicate the declaration is conditioned on, where it is /// conditioned at all. target: Option, } -impl ResolvedDependency { - /// The package the edge reaches. +impl NormalizedDependency { + /// The package the declaration names. pub(crate) fn package(&self) -> &str { &self.name } @@ -502,18 +532,18 @@ impl ResolvedDependency { EdgeKind::reported(self.kind.as_deref()) } - /// The platform predicate, where the edge is conditioned. + /// The platform predicate, where the declaration is conditioned. pub(crate) fn target(&self) -> Option<&str> { self.target.as_deref() } } -/// Asks cargo what it resolved, or states why nobody asked. +/// 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 resolution: an empty resolution would -/// answer "the core reaches no tooling" about a tree cargo never opened. -fn resolve(root: &Path, files: &CanonicalFileMap) -> Read { +/// 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); } @@ -541,8 +571,8 @@ fn resolve(root: &Path, files: &CanonicalFileMap) -> Read { String::from_utf8_lossy(&output.stderr).trim(), )); } - match serde_json::from_slice::(&output.stdout) { - Ok(resolved) => Read::Known(resolved), + 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(), diff --git a/xtask/src/repository/markdown.rs b/xtask/src/repository/markdown.rs index b415005..738ec2c 100644 --- a/xtask/src/repository/markdown.rs +++ b/xtask/src/repository/markdown.rs @@ -149,53 +149,80 @@ impl MarkdownDocument { /// 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. + /// 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 = self.blocks.iter().filter(|block| block.schema == schema); - let Some(found) = declaring.next() else { - return Read::DeclaredAbsent(AbsenceReason::NoBlockDeclaresThisSchema); - }; - if declaring.next().is_some() { + 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", )); } - Read::Known(found) + match declaring.first().copied() { + Some(block) => Read::Known(block), + None => Read::DeclaredAbsent(AbsenceReason::NoBlockDeclaresThisSchema), + } } - /// Every data-language block whose schema this repository does not - /// recognize. + /// Every data-language block no reading can be about, one offence each. /// - /// Reported rather than skipped. A block written in the data language that - /// declares no schema is a block no reading is about, and a ledger that - /// silently stopped being read is the exact failure this model was built to - /// end. - pub(crate) fn unrecognized_data_blocks(&self) -> usize { - self.blocks - .iter() - .filter(|block| block.schema == BlockSchema::UnrecognizedData) - .count() + /// 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 its declared schema with -/// it. +/// 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 { - schema: BlockSchema::declared_by(&language, &carried), + reading: read_block(&language, &carried), body: carried, }); } -/// Which schema one fenced block declares. +/// One schema this repository reads. /// -/// Identity is taken from the keys the block writes at its own document level, +/// 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 @@ -210,32 +237,28 @@ pub(crate) enum BlockSchema { ToolingObligationLedger, /// `seat` and `state`: a reserved architectural coordinate. SeatReservation, - /// A data-language block declaring no schema this repository reads. - UnrecognizedData, - /// A fenced block that is not written in the data language at all — a - /// diagram, a shell transcript, a Rust example. - NotData, } +/// 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 schema a block declares, read off the keys it writes at its own - /// document level. - fn declared_by(language: &str, body: &str) -> Self { - if language != DATA_LANGUAGE { - return BlockSchema::NotData; - } - let keys = document_keys(body); - let declares = |key: &str| keys.iter().any(|written| written == key); - if declares("obligations") { - BlockSchema::ObligationLedger - } else if declares("tooling-obligation") { - BlockSchema::ToolingObligationLedger - } else if declares("phase") { - BlockSchema::PhaseDeclaration - } else if declares("seat") && declares("state") { - BlockSchema::SeatReservation - } else { - BlockSchema::UnrecognizedData + /// 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"], } } @@ -246,12 +269,82 @@ impl BlockSchema { BlockSchema::ObligationLedger => "the obligation ledger block", BlockSchema::ToolingObligationLedger => "the tooling obligation ledger block", BlockSchema::SeatReservation => "the seat reservation block", - BlockSchema::UnrecognizedData => "a data block declaring no known schema", - BlockSchema::NotData => "a block that is not data", } } } +/// 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. @@ -265,10 +358,10 @@ fn document_keys(body: &str) -> Vec { .collect() } -/// One data block: the schema it declares and the text it carries. +/// 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. - schema: BlockSchema, + reading: BlockReading, /// The block's own text, as the parser handed it back. body: String, } @@ -921,19 +1014,95 @@ mod tests { ); } - /// A data block declaring no schema this repository reads is COUNTED rather - /// than skipped, so a ledger that quietly stopped being recognized is + /// 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_counted() { + fn a_data_block_declaring_no_schema_is_reported() { let document = MarkdownDocument::parse("```yaml\nsomething: else\nentirely: true\n```\n"); - assert_eq!(document.unrecognized_data_blocks(), 1); + 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] diff --git a/xtask/src/repository/mod.rs b/xtask/src/repository/mod.rs index 53dfa4b..2655ff0 100644 --- a/xtask/src/repository/mod.rs +++ b/xtask/src/repository/mod.rs @@ -6,10 +6,10 @@ //! 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 resolves, [`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. +//! [`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 //! snapshot every reading is carried in, then the three decoders. diff --git a/xtask/src/repository/rust.rs b/xtask/src/repository/rust.rs index 9c5297c..9bd434d 100644 --- a/xtask/src/repository/rust.rs +++ b/xtask/src/repository/rust.rs @@ -101,4 +101,109 @@ impl RustSyntaxSnapshot { } 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 index 86d0136..8da28f2 100644 --- a/xtask/src/repository/snapshot.rs +++ b/xtask/src/repository/snapshot.rs @@ -27,7 +27,7 @@ use std::collections::BTreeMap; use std::error::Error; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -38,12 +38,28 @@ use crate::repository::markdown::MarkdownSnapshot; use crate::repository::rust::RustSyntaxSnapshot; use crate::repository::types::{AbsenceReason, CanonicalPath, LinkState, Read, ReadFailure}; -/// Directories the reading never enters. +/// Git's own storage, AT THE REPOSITORY ROOT. /// -/// `.git` is git's own storage and `target` is the build's; neither is -/// repository material, and both are large enough that walking them would make -/// every run pay for bytes no law is about. -const UNREAD_DIRECTORIES: [&str; 2] = [".git", "target"]; +/// 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. /// @@ -71,28 +87,45 @@ pub(crate) struct RepositorySnapshot { rust: RustSyntaxSnapshot, /// Every Markdown document, parsed once. markdown: MarkdownSnapshot, - /// What git says the reading was taken at. - git: GitSnapshot, + /// 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: the files first, - /// because every other reading is over them; then the three decoders, each - /// authoritative for its own language; then git, which names what was read. + /// 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); - let git = GitSnapshot::read(root); Ok(Self { files, cargo, rust, markdown, - git, + binding, }) } @@ -116,14 +149,9 @@ impl RepositorySnapshot { &self.markdown } - /// The commit this reading was taken at. - pub(crate) const fn commit(&self) -> &Read { - &self.git.commit - } - - /// The tree that commit names. - pub(crate) const fn tree(&self) -> &Read { - &self.git.tree + /// What the bytes this reading carries are bound to. + pub(crate) const fn binding(&self) -> &CommitBinding { + &self.binding } } @@ -135,7 +163,8 @@ pub(crate) struct CanonicalFileMap { } impl CanonicalFileMap { - /// Reads every file under one root, skipping [`UNREAD_DIRECTORIES`]. + /// 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 @@ -234,6 +263,20 @@ impl FileFact { /// 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, @@ -250,17 +293,23 @@ fn read_directory( 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 = name.to_string_lossy().into_owned(); - let spelled = if inside.is_empty() { - named.clone() + 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 { - if !UNREAD_DIRECTORIES.contains(&named.as_str()) { - read_directory(&path, &spelled, into)?; - } + read_directory(&path, &spelled, into)?; continue; } into.insert(CanonicalPath::spelled(&spelled), read_file(&path)); @@ -268,6 +317,44 @@ fn read_directory( 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(); @@ -301,37 +388,224 @@ fn read_file(path: &Path) -> FileFact { FileFact { link, bytes, text } } -/// What git says the reading was taken at. -struct GitSnapshot { +/// 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: Read, + commit: CommitId, /// The tree that commit names. - tree: Read, + tree: TreeId, } -impl GitSnapshot { - /// Asks git what `HEAD` names, or states that this root is not a checkout. +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. /// - /// It names the COMMITTED state the run started from, and deliberately not - /// the bytes that were read: the files map is what was read. Naming both is - /// what lets a log say which tree a green verdict was about — a campaign has - /// already produced one false green from a restore that preserved a - /// modification time, and a run that prints the commit it judged is a run - /// that cannot be confused with a different one. - fn read(root: &Path) -> Self { - if !root.join(".git").exists() { - return Self { - commit: Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout), - tree: Read::DeclaredAbsent(AbsenceReason::NotAGitCheckout), - }; + /// 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}" + ), } - Self { - commit: revision(root, "HEAD").map(CommitId), - tree: revision(root, "HEAD^{tree}").map(TreeId), + } +} + +/// 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") @@ -429,21 +703,209 @@ pub(crate) fn repository_snapshot() -> Result<&'static RepositorySnapshot, Strin /// Planted reversals for the reading itself. #[cfg(test)] mod tests { - use super::repository_snapshot; - use crate::repository::types::Read; + 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. + /// The reading names what it read, and never names a commit it did not + /// bind. /// - /// A run that cannot say which commit it judged is a run whose green cannot - /// be attached to a tree, and this campaign has already produced one false - /// green from a restore that preserved a modification time. + /// 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_names_the_commit_it_was_taken_at() -> Result<(), String> { + fn the_reading_never_names_a_commit_it_did_not_bind() -> Result<(), String> { let snapshot = repository_snapshot()?; - let commit = snapshot.commit().required("the commit HEAD names")?; - assert_eq!(commit.to_string().len(), 40, "{commit}"); - let tree = snapshot.tree().required("the tree HEAD names")?; - assert_eq!(tree.to_string().len(), 40, "{tree}"); + 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(()) } diff --git a/xtask/src/repository/types.rs b/xtask/src/repository/types.rs index 49536ee..43f57ea 100644 --- a/xtask/src/repository/types.rs +++ b/xtask/src/repository/types.rs @@ -114,7 +114,7 @@ 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 - /// resolve and no resolution to ask about. + /// read and no reading to ask about. NotAWorkspaceCheckout, /// The root is not a git checkout, so no commit names what was read. NotAGitCheckout, @@ -129,7 +129,7 @@ impl fmt::Display for AbsenceReason { 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 resolves nothing here" + "the root declares no Cargo.toml, so cargo reports nothing here" } AbsenceReason::NotAGitCheckout => "the root is not a git checkout", AbsenceReason::NoBlockDeclaresThisSchema => { From 15361cfb1a73ed38ee7c949de55310f35d62b494 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 09:27:12 -0400 Subject: [PATCH 7/9] Close five population holes at their class: every one printed PASS while its claim was false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT REMAINS UNPROVEN. Two ceilings are now stated rather than papered over, and both are the same one: `syn::Expr` is `#[non_exhaustive]`, so neither the seat walk nor the underscore-field walk enters an item declared inside an EXPRESSION — a block used as a value, a closure body, a match arm. A reader completing itself by listing that enum is complete until the decoder gains one variant, which is the set-with-no-last-member shape this repository has already deleted readers for. The opening condition is named at both sites: the gap closes when the one reading gains a visitor owning the whole grammar, not when a law is taught one more shape. `is_the_marker` likewise admits a LOCAL type named `PhantomData`; separating it from the marker is name resolution, and the narrowing deliberately stops short of it. A two-line block-to-items adapter is now written in both seat.rs and hygiene.rs; its shared home is `repository/rust.rs`, which another agent owns this wave. DENOMINATORS, BEFORE AND AFTER, UNMOVED. read 311 files; red twins (core) 21 discharged / 179 owed; tooling reversals 18 discharged / 3 owed; collection bodies 27 coupled / 27 declared; seat modules 7 carrying one record alone / 7 declared; inhabitant-promising limits 8 witnessed / 8 declared; committed manifest census 19 entries; repository laws 18. `cargo xtask qualify` runs all seven stages green on these bytes. WHAT WAS BUILT. Five silent under-reports, each closed at its class rather than at the instance that was reported. 1. seat.rs admitted a QUALIFIED self type. `head_of` reduced an implementation's subject to its last path segment, so `impl crate::other::Foo` inside a seat module declaring `Foo` read as `Foo` and passed — while Rust admits an inherent implementation in a module other than the type's own, so that block really does sit inside the local record's privacy wall and can construct or return it. The subject is now classified: unqualified, single-segment, that segment the record's identifier, generic arguments permitted there. No `qself`, no leading `::`, no second segment. The ground is stated and is syntax-only — a module declaring `struct Foo` cannot also import another `Foo` into its type namespace, so the admitted spelling's meaning is settled by the declaration beside it. Nothing is resolved. SWEPT, same file: the walk entered item-level modules only, so a `seat` module written inside a free road, an implementation method, or a trait default stood outside the population entirely. It now enters all three. 2. `underscore-fields-are-phantom` missed METHOD bodies. The walk descended into free `Item::Fn` bodies and ignored `Item::Impl` and `Item::Trait`, so a real underscore field declared inside a method was outside a population the law states repository-wide — a regression against the whole-source scan this replaced. It now descends into method and default bodies. 3. The same law accepted anything CONTAINING `PhantomData`. The predicate asked whether the marker appeared at any depth, so `_hidden: (u64, PhantomData)` passed while carrying a readable `u64`. The outer declared type must now BE `PhantomData<…>`; parentheses and invisible groups are unwrapped because neither is a type, and references, pointers, tuples, arrays, slices and containers refuse. 4. `lint-wall-inherited` never checked the ROOT package. It iterated `[workspace].members`, and the root package is a member Cargo never asks anybody to list — so deleting the machine crate's own `[lints] workspace = true` took it out from under the one wall and this check printed PASS. SWEPT PAST THE REPORTED INSTANCE: a path dependency inside the workspace directory is an implicit member too, so the population is now DERIVED from the tree — every `Cargo.toml` declaring a `[package]` table — with the workspace's own `exclude` as the single exemption, read off that declaration. The members array is still read for the one claim only it can make: a listed member must be one of those packages. 5a. `declared_module_order` excluded every module carrying ANY `cfg`. That correctly skipped `#[cfg(test)] mod laws;` and also let `#[cfg(unix)] mod production_home;` reach forward without ever entering the order. RULING: only the exact proof-surface condition is admitted, and every other conditioned declaration REFUSES — the reason being the law's own subject, that this reading establishes one order and a module compiled in some builds and not others stands in as many orders as there are build populations. The alternative is unrepresentable rather than discouraged: the reader refuses the declaration instead of passing over it. The same rule now covers the machine's band declarations, and `cfg_attr` counts as a condition. 5b. The band population RECOGNIZED `NN_` directories and let everything else fall through a silent `continue`. Every direct child of `src/` now resolves to exactly one of four states — numbered semantic home, crate root, reserved root file, invalid entry — and nothing reaches a silent continue. SWEPT, same file: `macros/macroc/src` had the identical hole from the other side, where a source no `mod` declaration accounts for stands in no order at all; its children are now classified total the same way. And `module_references` preferred `name.rs` when both `name.rs` and `name/` existed, leaving every source under the directory unread; that ambiguity now refuses. REVERSALS, EXECUTED AGAINST THE REAL TREE, then reverted: a qualified impl planted in `macros/macroc/src/refusal/type_guard.rs` (seat modules 6/7); an underscore field in an implementation method and in a trait default; `_hidden: (u64, PhantomData)`; the root package's lints inheritance stripped (`Cargo.toml does not inherit the lint wall`); `#[cfg(unix)] pub mod production_home;` in the services root; `src/2_4_orchestration/` and `src/notes.md`. Each is also committed as a planted fixture beside its law. OUTSIDE THIS DIFF, NOT WIDENED. `check_toolchain_pin` joins four statements of the floor and no fifth exists in the tree — the hosted workflows install through the pin rather than restating a version — so that law has no sibling hole. Co-Authored-By: Claude Opus 5 (1M context) --- xtask/src/checks/hygiene.rs | 213 ++++++++--- xtask/src/checks/placement.rs | 649 ++++++++++++++++++++++++++++++---- xtask/src/checks/seat.rs | 299 ++++++++++++++-- xtask/src/checks/toolchain.rs | 188 +++++++++- 4 files changed, 1187 insertions(+), 162 deletions(-) diff --git a/xtask/src/checks/hygiene.rs b/xtask/src/checks/hygiene.rs index 83c681f..a31afb6 100644 --- a/xtask/src/checks/hygiene.rs +++ b/xtask/src/checks/hygiene.rs @@ -85,12 +85,33 @@ pub(crate) fn check_no_python(snapshot: &RepositorySnapshot) -> Result<(), Strin /// 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. /// -/// Two things a parse cannot reach, both failing CLOSED: a field written inside +/// 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; and a type alias that resolves TO `PhantomData` is -/// not recognized, because resolving an alias is the compiler's question rather -/// than a parse's. Each costs a lawful field a refusal it has to spell -/// differently, and neither admits a field carrying data. +/// 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 /// @@ -154,6 +175,40 @@ fn suppressed_fields<'items>(items: impl IntoIterator) 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 @@ -187,53 +242,46 @@ fn consider(field: &syn::Field, into: &mut Vec) { if !spelled.starts_with('_') { return; } - if !mentions_marker(&field.ty) { + if !is_the_marker(&field.ty) { into.push(spelled); } } -/// Whether one declared type mentions the type-level marker, at any depth. +/// 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. /// -/// A type this reader does not open contributes nothing, which is the -/// conservative direction here: it can refuse a lawful field, and it can never -/// admit one carrying data. -fn mentions_marker(declared: &syn::Type) -> bool { +/// 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 { - typed.path.segments.iter().any(|segment| { - segment.ident == TYPE_LEVEL_MARKER || marker_in_arguments(&segment.arguments) - }) - } else if let syn::Type::Reference(borrowed) = declared { - mentions_marker(&borrowed.elem) - } else if let syn::Type::Ptr(pointer) = declared { - mentions_marker(&pointer.elem) - } else if let syn::Type::Paren(parenthesized) = declared { - mentions_marker(&parenthesized.elem) - } else if let syn::Type::Group(grouped) = declared { - mentions_marker(&grouped.elem) - } else if let syn::Type::Tuple(tuple) = declared { - tuple.elems.iter().any(mentions_marker) - } else if let syn::Type::Array(array) = declared { - mentions_marker(&array.elem) - } else if let syn::Type::Slice(sliced) = declared { - mentions_marker(&sliced.elem) - } else { - false + return typed.qself.is_none() + && typed.path.segments.last().is_some_and(|last| { + last.ident == TYPE_LEVEL_MARKER + && matches!(last.arguments, syn::PathArguments::AngleBracketed(_)) + }); } -} - -/// Whether one segment's arguments mention the type-level marker. -fn marker_in_arguments(arguments: &syn::PathArguments) -> bool { - if let syn::PathArguments::AngleBracketed(angled) = arguments { - angled.args.iter().any(|argument| { - if let syn::GenericArgument::Type(inner) = argument { - mentions_marker(inner) - } else { - false - } - }) - } else { - false + 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. @@ -361,4 +409,81 @@ mod tests { "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/placement.rs b/xtask/src/checks/placement.rs index 5d7704e..52bc14d 100644 --- a/xtask/src/checks/placement.rs +++ b/xtask/src/checks/placement.rs @@ -17,8 +17,28 @@ //! 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::BTreeSet; +use std::collections::BTreeMap; use std::str::FromStr; use proc_macro2::{Delimiter, TokenStream, TokenTree}; @@ -36,6 +56,18 @@ const MACHINE_ROOT: &str = "src/lib.rs"; /// 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"; @@ -43,12 +75,45 @@ const PATH_ATTRIBUTE: &str = "path"; /// stand in. const CONDITION_ATTRIBUTE: &str = "cfg"; +/// 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. +/// +/// # 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 bands = band_directories(snapshot); let mut offenders = Vec::new(); + let mut bands = Vec::new(); + 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()), + } + } + 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 HOME_FILES { if snapshot @@ -64,7 +129,7 @@ pub(crate) fn check_band_map(snapshot: &RepositorySnapshot) -> Result<(), String .rust() .source(&CanonicalPath::spelled(MACHINE_ROOT)) .taken(MACHINE_ROOT)?; - let declared = band_declarations(root); + let declared = band_declarations(root, &mut offenders); let mut positions = Vec::new(); for band in &bands { match declared.iter().position(|stated| stated == band) { @@ -86,53 +151,183 @@ pub(crate) fn check_band_map(snapshot: &RepositorySnapshot) -> Result<(), String } } -/// Every numbered band directory the machine's tree carries, in ascending band -/// order. +/// What one direct child of the machine's source directory is. /// -/// A band is a directory whose name opens with two digits and an underscore. -/// The set is derived from the reading rather than from a list anybody -/// maintains. -fn band_directories(snapshot: &RepositorySnapshot) -> Vec { - let mut bands = BTreeSet::new(); +/// 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; }; - let Some((head, _)) = tail.split_once('/') else { - continue; - }; - let Some((number, _)) = head.split_once('_') else { - continue; - }; - if number.len() == 2 && number.chars().all(|digit| digit.is_ascii_digit()) { - bands.insert(head.to_owned()); + match tail.split_once('/') { + Some((head, _)) => { + children.insert(head.to_owned(), classified_directory(head)); + } + None => { + children.insert(tail.to_owned(), classified_root_file(tail)); + } } } - bands.into_iter().collect() + children } -/// The band directories one crate root declares, in declaration order. +/// 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) -> Vec { - root.items.iter().filter_map(declared_band).collect() +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 declared_band(item: &syn::Item) -> Option { +fn band_declaration(item: &syn::Item) -> Option { let syn::Item::Mod(module) = item else { return None; }; - module.attrs.iter().find_map(|attribute| { + 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. @@ -195,25 +390,50 @@ fn string_attribute(attribute: &syn::Attribute, named: &str) -> Option { /// 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. +/// 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}/lib.rs"); + let root_path = format!("{TOOLING_SOURCE}/{CRATE_ROOT_FILE}"); let root = snapshot .rust() .source(&CanonicalPath::spelled(&root_path)) .taken(&root_path)?; - let order = declared_module_order(root); + 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!("{root_path} declares no modules")); + violations.push(format!( + "{root_path} declares no module standing in the order" + )); } let mut modules = Vec::new(); for name in &order { 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 { @@ -221,28 +441,135 @@ pub(crate) fn check_tooling_module_order(snapshot: &RepositorySnapshot) -> Resul } } -/// The module names one crate root 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 a build -/// CONDITION does not: the proof surface is outside the order by construction. -fn declared_module_order(root: &syn::File) -> Vec { - root.items - .iter() - .filter_map(|item| { - let syn::Item::Mod(module) = item else { - return None; - }; - if module - .attrs - .iter() - .any(|attribute| attribute.path().is_ident(CONDITION_ATTRIBUTE)) - { - return None; - } - Some(module.ident.to_string()) - }) - .collect() +/// 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." + )), + } + } + 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. @@ -255,28 +582,38 @@ fn module_references( snapshot: &RepositorySnapshot, name: &str, ) -> Result<(Vec, ModuleLayout), String> { - let flat = format!("{TOOLING_SOURCE}/{name}.rs"); - if snapshot.files().get(&flat).is_some() { - let text = snapshot.files().text(&flat).taken(&flat)?; - return Ok((references_of(text, ModuleLayout::Flat)?, ModuleLayout::Flat)); - } + let flat = format!("{TOOLING_SOURCE}/{name}{SOURCE_SUFFIX}"); let directory = format!("{TOOLING_SOURCE}/{name}"); - let mut found = Vec::new(); - let mut carried = false; - for (path, _) in snapshot.files().under(&directory) { - if !path.extension_is("rs") { - continue; + 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)) } - carried = true; - let text = snapshot.files().text(path.as_str()).taken(path.as_str())?; - found.extend(references_of(text, ModuleLayout::Directory)?); - } - if carried { - Ok((found, ModuleLayout::Directory)) - } else { - Err(format!( + (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)?); + } + Ok((found, ModuleLayout::Directory)) + } + // 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}/" - )) + )), } } @@ -511,8 +848,8 @@ fn module_order_violations( #[cfg(test)] mod tests { use super::{ - check_band_map, check_tooling_module_order, declared_module_order, module_order_violations, - references_of, + 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; @@ -548,8 +885,9 @@ mod tests { 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\nmod\n wrapped;\n"; + let declared = declared_modules(&root(lib)?); assert_eq!( - declared_module_order(&root(lib)?), + declared.order, vec![ String::from("plane"), String::from("refusal"), @@ -557,6 +895,47 @@ mod tests { 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(()) } @@ -737,4 +1116,124 @@ mod tests { 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/seat.rs b/xtask/src/checks/seat.rs index c9d5615..e465e41 100644 --- a/xtask/src/checks/seat.rs +++ b/xtask/src/checks/seat.rs @@ -41,12 +41,29 @@ //! //! 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; whether some other `Foo` is -//! in scope is a question this law never asks, because the answer cannot change -//! the verdict — the seat module declares exactly one record, and an inherent -//! implementation written inside it can only be for something declared or -//! imported there. +//! 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 //! @@ -67,6 +84,24 @@ //! 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; @@ -139,11 +174,21 @@ fn seat_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> SeatVerdict { verdict } -/// Walks one module's items, judging every `seat` module it declares and -/// descending into every other module to find the ones nested deeper. -fn walk(path: &str, items: &[syn::Item], verdict: &mut SeatVerdict) { +/// 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; @@ -166,6 +211,55 @@ fn walk(path: &str, items: &[syn::Item], verdict: &mut SeatVerdict) { } } +/// 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(); @@ -232,23 +326,87 @@ fn judge_implementation( )); return; } - let Some(subject) = head_of(&declared.self_ty) else { - verdict.offenders.push(format!( + 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" - )); - return; + )), + } +} + +/// 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 !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" - )); + 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 @@ -297,20 +455,6 @@ fn described(item: &syn::Item) -> &'static str { "an item this reader has no name for" } -/// 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 { - typed - .path - .segments - .last() - .map(|last| last.ident.to_string()) - } else { - None - } -} - /// Every parsed source the population is derived from: the machine's own /// sources and the services'. /// @@ -491,6 +635,95 @@ mod tests { 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() { diff --git a/xtask/src/checks/toolchain.rs b/xtask/src/checks/toolchain.rs index 5047dc4..c9c6a8f 100644 --- a/xtask/src/checks/toolchain.rs +++ b/xtask/src/checks/toolchain.rs @@ -18,7 +18,7 @@ use crate::repository::cargo::{ }; use crate::repository::markdown::phase_declaration; use crate::repository::snapshot::RepositorySnapshot; -use crate::repository::types::CanonicalPath; +use crate::repository::types::{CanonicalPath, Read}; /// The file that pins the channel every build runs under. const TOOLCHAIN_PIN: &str = "rust-toolchain.toml"; @@ -29,6 +29,11 @@ const LINT_CONFIGURATION: &str = "clippy.toml"; /// 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. /// /// Four files say what this workspace builds on: `rust-toolchain.toml` pins the @@ -104,11 +109,36 @@ pub(crate) fn check_workspace_members(snapshot: &RepositorySnapshot) -> Result<( } } -/// The root manifest declares the one lint wall and every member inherits it. +/// 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() @@ -120,19 +150,73 @@ pub(crate) fn check_lint_wall(snapshot: &RepositorySnapshot) -> Result<(), Strin "root {MANIFEST_FILE} has no [workspace.lints.rust] wall" )); } - let members = strings_at(manifest, &["workspace", "members"]).taken("the workspace members")?; - let mut missing = Vec::new(); - for member in members { - let path = format!("{member}/{MANIFEST_FILE}"); - let document = snapshot.cargo().document(&path).taken(&path)?; + 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) { - missing.push(member); + offenders.push(format!("{path} does not inherit the lint wall")); } } - if missing.is_empty() { + if packages.is_empty() { + return Err(String::from( + "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", + )); + } + 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 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}" + )), } } @@ -243,6 +327,90 @@ mod tests { 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. /// From 0975772f32ee718d6471b3eb4f93d61d93cdebcb Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 09:33:55 -0400 Subject: [PATCH 8/9] Give every limit family one capacity authority, and refuse a name two homes spell alike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT REMAINS UNPROVEN. `LimitWitness` still has only its crate-internal `#[cfg(test)]` mint, so production schema validation can neither mint a runtime capacity nor consume one; what this diff establishes is the ALGEBRA and the declaration-side population guard, never 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 the seven collection-shaped bodies whose families are on the runtime ladder cannot consume `PositiveLimitWitness` at all — the witness exists and nothing among them takes it; the opening condition is a prefix road that takes the runtime witness. Both absences are now written at the types themselves and in the README, and neither is closed here. The population leg still reads the machine's sources alone: the services declare their thirty-four families through one transcriber no syntax reader expands, so a services family is outside the derivation and the module says so. Declaring an authority is not supplying a magnitude: a family may name `DeclaredMagnitude` and never implement `ConstLimit`, which is inert rather than wrong, and no bound here judges it. DENOMINATORS, before and after. Red twins (core) 21 discharged / 179 owed → 22 / 179; the one new discharge is this diff's own reversal and no owed row moved. Tooling reversals 18 discharged / 3 owed, unmoved. Collection bodies 27 coupled / 27 declared, unmoved. Seat modules 7 / 7, unmoved. Inhabitant-promising limits 8 witnessed / 8 declared, unmoved — the guard around that population changed and the population did not. Manifest census 19 entries, unmoved. Repository laws 18, unmoved: the collision refusal is an offence of the leg that derives the population rather than a nineteenth name, because a reading that cannot say which family a seat named has no denominator to count and the refusal belongs where the denominator is built. Files read 311 → 313, the two being the new fixture and its recorded diagnostic. The `impl Limit` population was derived by grep and is 106 sites: 70 in the machine's homes (18 declared magnitude, 8 evidence-selected, 44 unstated), 20 in the proof surface, 11 in testpak's fixtures, and one transcriber row standing for the services' 34. THE ARTIFACT. `Limit` carries `type Authority: CapacityAuthority`, and the two ladders name theirs exactly — `ConstLimit: Limit` and `EvidenceSelectedLimit: Limit`. An associated type resolves to one type, so a family declaring both ladders is a type mismatch at its own declaration. `UnstatedMagnitude` is the third state and the largest: a family bounding only a `Bounded` seat needs no magnitude, and the marker makes the families whose prose says "schema-witnessed" while their declaration says nothing say THAT in the type system. The reversal is executed and recorded: `testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs`, whose committed diagnostic reads `error[E0271]: type mismatch resolving ::Authority == EvidenceSelectedMagnitude`. Its green half is `laws.rs root::a_family_declares_one_capacity_authority`, which asserts the exclusion nowhere and could not: the exclusion has no expression in this crate. What that law holds is the positive control — that both bounds are satisfiable, and that the third state is inhabited — because a pair of ladders nothing could implement would refuse every reversal ever written against it. `positivity.rs` keyed a family by its LAST PATH SEGMENT, so two homes declaring an ordinary name — `IssueLimit` is the obvious one — would have collapsed into one record, folding one home's ladder onto the other home's seat, silently and in the direction that makes the offence vanish. A terminal name declared at more than one SITE now refuses and leaves the population; a site is the file plus its inline module chain, so two `mod` blocks in one file collide exactly as two files do. It is refused rather than qualified because the seat side carries only a terminal name and has no owner to qualify against — until a generated declaration contract supplies an owner-qualified identity BOTH sides carry, refusing loud is the honest move. Measured on this tree: no collision exists today, and the measurement is a test (`the_real_machine_declares_no_family_name_twice`) rather than a sentence in a report. Three planted reversals stand beside it: two homes, two inline modules, and the positive control that one family named many times at one site is not a collision. DRAINED, because the type system now holds what they asserted. Three prose claims that a family declaring both authorities was a defect nothing could see are gone rather than moved: the nonclaim in `EvidenceSelectedLimit`'s rustdoc, the second claim ceiling in `laws.rs root::the_runtime_ladder_is_declared_by_its_family`, and the matching stated ceiling in `positivity.rs`'s module documentation. No law was deleted — the leg that would have re-derived the same refusal by parsing was never written, and writing it now would have been the weaker restatement that keeps passing after the supertrait bounds are gone. FOUND AND REPAIRED, named because it is outside this diff's subject: the same law's rustdoc said the seat side of the ladder join "remains owed; no `cargo xtask check` law derives it", which stopped being true when `inhabitant-promising-limits-are-witnessed` landed. It now names that leg. FOUND AND NOT ACTED ON, named rather than absorbed. `Bounded::admitted` takes `&LimitWitness` under `L: Limit`, so a family whose authority is `DeclaredMagnitude` can still be handed an evidence-selected magnitude — the same two-authorities shape one rung down, at the BASE witness rather than at the positive one. The proof surface already does it: `root::bounded_construction_is_a_seam` mints a `LimitWitness` for a `ConstLimit` family. It has no production consequence today because that mint is `#[cfg(test)]`, and tightening the bound is a change to the base algebra with its own reversal and its own law prose, not a widening of this diff. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 57 ++++- macros/macroc/src/plane/types.rs | 17 +- src/04_numeric/types.rs | 6 +- src/06_authority/types.rs | 36 ++- src/08_schema/types.rs | 48 +++- src/09_time/types.rs | 14 +- src/10_history/types.rs | 49 +++- src/11_navigation/types.rs | 16 +- src/12_port/types.rs | 6 +- src/13_declaration/types.rs | 33 ++- src/14_semantic/types.rs | 22 +- src/15_execution/types.rs | 45 +++- src/16_image/types.rs | 6 +- src/17_pakvm/types.rs | 10 +- src/18_bvisor/types.rs | 12 +- src/19_runtime/types.rs | 10 +- src/22_security/types.rs | 6 +- src/23_evidence/types.rs | 10 +- src/laws.rs | 192 +++++++++++++--- src/types.rs | 158 ++++++++++++- ...apacity-minted-for-an-undeclared-family.rs | 11 +- ...ity-minted-for-an-undeclared-family.stderr | 8 +- .../a-capacity-witness-from-another-family.rs | 12 +- ...apacity-witness-from-another-family.stderr | 8 +- .../compile-fail/a-cross-profile-admission.rs | 8 +- .../a-cross-profile-admission.stderr | 8 +- ...ily-declaring-both-capacity-authorities.rs | 56 +++++ ...declaring-both-capacity-authorities.stderr | 16 ++ .../a-magnitude-past-the-authoring-ceiling.rs | 8 +- ...ing-family-cannot-mint-a-positive-limit.rs | 8 +- .../a-remainder-married-to-another-body.rs | 8 +- ...a-remainder-married-to-another-body.stderr | 8 +- ...mum-family-cannot-mint-a-positive-limit.rs | 7 +- .../singleton-under-a-zero-maximum-family.rs | 6 +- xtask/src/checks/positivity.rs | 215 ++++++++++++++++-- 35 files changed, 952 insertions(+), 188 deletions(-) create mode 100644 testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.rs create mode 100644 testpak/tests/compile-fail/a-family-declaring-both-capacity-authorities.stderr diff --git a/README.md b/README.md index a1ff580..b129ab1 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,41 @@ 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`, @@ -167,7 +202,23 @@ 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. A drift detector stands over the other side +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` @@ -332,6 +383,10 @@ obligations: 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/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/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 c834ca6..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; // --------------------------------------------------------------------------- @@ -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 04d4cc7..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; // --------------------------------------------------------------------------- @@ -402,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 @@ -474,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, @@ -604,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; @@ -644,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 @@ -734,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 @@ -898,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)] @@ -991,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)] @@ -1091,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; } @@ -1142,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; } @@ -1197,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/types.rs b/src/11_navigation/types.rs index b29f7a4..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; @@ -63,7 +65,9 @@ crate::scope_guard_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 5d3df59..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. @@ -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/types.rs b/src/13_declaration/types.rs index 6521a21..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 @@ -698,7 +703,9 @@ pub enum AuthoredNameConstructionIssue { /// [`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 @@ -822,7 +829,9 @@ pub enum ClosureNamespaceIssue { /// [`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 @@ -880,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 @@ -950,7 +961,9 @@ pub enum LinkResolutionIssue { /// [`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 @@ -1018,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; } @@ -1072,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 a6a3c43..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. @@ -203,7 +203,9 @@ pub enum SemanticFormConstructionIssue { /// 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 @@ -263,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)] @@ -279,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 @@ -297,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. @@ -331,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 581a26e..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. @@ -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)] @@ -327,7 +334,9 @@ pub enum ExecutionFormConstructionIssue { /// [`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 @@ -398,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 @@ -560,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)] @@ -695,7 +710,9 @@ pub enum EffectBatchCompositionIssue { /// [`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 @@ -925,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. @@ -981,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. /// @@ -1084,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 5713749..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. @@ -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 39e3d1b..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; // --------------------------------------------------------------------------- @@ -368,7 +370,9 @@ pub enum AttemptAdmissionIssue { /// [`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 @@ -797,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/22_security/types.rs b/src/22_security/types.rs index dd9add9..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. @@ -278,7 +278,9 @@ pub struct ResultingResolutionDomain; /// 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 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 adefff6..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; } @@ -388,7 +405,9 @@ mod root { NonEmptyBoundedConstruction, PositiveLimitWitness, }; struct SelectedDemo; - impl Limit for 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 @@ -439,10 +458,14 @@ mod root { fn a_capacity_witness_does_not_cross_families() { use crate::types::{EvidenceSelectedLimit, PositiveLimitWitness}; struct FirstDemo; - impl Limit for FirstDemo {} + impl Limit for FirstDemo { + type Authority = EvidenceSelectedMagnitude; + } impl EvidenceSelectedLimit for FirstDemo {} struct SecondDemo; - impl Limit for SecondDemo {} + impl Limit for SecondDemo { + type Authority = EvidenceSelectedMagnitude; + } impl EvidenceSelectedLimit for SecondDemo {} let first = PositiveLimitWitness::inhabited(LimitWitness::::declared(4)) @@ -470,16 +493,21 @@ mod root { /// 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, in two parts. The declaration says the magnitude - /// arrives at runtime; it does NOT say the family declares no compile-time - /// magnitude, and a family stating both would be stating two authorities for - /// one capacity — a declaration defect no bound here can see. And it does - /// not say that every family in this crate whose seat promises an inhabitant - /// has made the declaration: that 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. + /// 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 @@ -489,8 +517,12 @@ mod root { /// 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 remains owed; no - /// `cargo xtask check` law derives it. + /// 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 — @@ -499,7 +531,9 @@ mod root { fn the_runtime_ladder_is_declared_by_its_family() { use crate::types::{CapacityAdmission, EvidenceSelectedLimit, PositiveLimitWitness}; struct DeclaredDemo; - impl Limit for DeclaredDemo {} + impl Limit for DeclaredDemo { + type Authority = EvidenceSelectedMagnitude; + } impl EvidenceSelectedLimit for DeclaredDemo {} let mint: fn( @@ -509,6 +543,73 @@ mod root { 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 @@ -536,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; } @@ -586,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; } @@ -629,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; } @@ -717,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 { @@ -1015,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()); } @@ -1099,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; } @@ -1195,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; } @@ -1883,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, @@ -2000,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()); diff --git a/src/types.rs b/src/types.rs index 7c151b4..5f509dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -58,16 +58,117 @@ 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; } @@ -100,11 +201,19 @@ pub trait ConstLimit: Limit { /// 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. It also -/// does not claim the family declares no compile-time magnitude: a family -/// implementing both this and [`ConstLimit`] would be stating two authorities -/// for one capacity, and that is a declaration defect no bound here can see. -pub trait EvidenceSelectedLimit: Limit {} +/// 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. /// @@ -347,6 +456,22 @@ impl PositiveLimit { /// 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 { @@ -434,6 +559,25 @@ pub enum CapacityAdmission { /// 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"] 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 index bc519ad..18c2d84 100644 --- 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 @@ -23,13 +23,16 @@ //! from the sources is still owed. use threadpak::types::{ - CapacityAdmission, EvidenceSelectedLimit, Limit, LimitWitness, PositiveLimitWitness, + CapacityAdmission, EvidenceSelectedLimit, EvidenceSelectedMagnitude, Limit, LimitWitness, + PositiveLimitWitness, UnstatedMagnitude, }; /// A family whose owner declared the magnitude evidence-selected. struct DeclaredFamily; -impl Limit for DeclaredFamily {} +impl Limit for DeclaredFamily { + type Authority = EvidenceSelectedMagnitude; +} impl EvidenceSelectedLimit for DeclaredFamily {} @@ -37,7 +40,9 @@ impl EvidenceSelectedLimit for DeclaredFamily {} /// like any other; what it has not done is admit the runtime ladder. struct UndeclaredFamily; -impl Limit for UndeclaredFamily {} +impl Limit for UndeclaredFamily { + type Authority = UnstatedMagnitude; +} /// The lawful half, and it must stay lawful: the declared family reaches the /// mint. 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 index a0fbd41..fbe4230 100644 --- 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 @@ -1,13 +1,13 @@ error[E0277]: the trait bound `UndeclaredFamily: EvidenceSelectedLimit` is not satisfied - --> tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs:54:5 + --> tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs:59:5 | -54 | PositiveLimitWitness::inhabited; +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:38:1 + --> tests/compile-fail/a-capacity-minted-for-an-undeclared-family.rs:41:1 | -38 | struct UndeclaredFamily; +41 | struct UndeclaredFamily; | ^^^^^^^^^^^^^^^^^^^^^^^ = help: the following other types implement trait `EvidenceSelectedLimit`: AdmissionIssueLimit 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 index dbebe77..aa37649 100644 --- a/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs +++ b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.rs @@ -14,19 +14,25 @@ //! no public mint until the schema home carries the real declaration path — and //! this fixture is written to need none. -use threadpak::types::{EvidenceSelectedLimit, Limit, PositiveLimitWitness}; +use threadpak::types::{ + EvidenceSelectedLimit, EvidenceSelectedMagnitude, Limit, PositiveLimitWitness, +}; /// One family whose magnitude the owner's evidence selects. struct FirstFamily; -impl Limit for 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 {} +impl Limit for SecondFamily { + type Authority = EvidenceSelectedMagnitude; +} impl EvidenceSelectedLimit for SecondFamily {} 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 index 341321a..58eaee6 100644 --- a/testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr +++ b/testpak/tests/compile-fail/a-capacity-witness-from-another-family.stderr @@ -1,7 +1,7 @@ error[E0308]: mismatched types - --> tests/compile-fail/a-capacity-witness-from-another-family.rs:44:22 + --> tests/compile-fail/a-capacity-witness-from-another-family.rs:50:22 | -44 | admits_the_first(capacity); +50 | admits_the_first(capacity); | ---------------- ^^^^^^^^ expected `&PositiveLimitWitness`, found `&PositiveLimitWitness` | | | arguments to this function are incorrect @@ -9,7 +9,7 @@ error[E0308]: mismatched types = note: expected reference `&PositiveLimitWitness` found reference `&PositiveLimitWitness` note: function defined here - --> tests/compile-fail/a-capacity-witness-from-another-family.rs:34:4 + --> tests/compile-fail/a-capacity-witness-from-another-family.rs:40:4 | -34 | fn admits_the_first(_capacity: &PositiveLimitWitness) {} +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-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-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/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/src/checks/positivity.rs b/xtask/src/checks/positivity.rs index fcb981d..d6495d3 100644 --- a/xtask/src/checks/positivity.rs +++ b/xtask/src/checks/positivity.rs @@ -83,11 +83,39 @@ //! magnitude for it is a behavioural fact no parse reaches, and the machine //! carries no runtime yet. //! -//! **It does not judge a family declaring BOTH ladders.** Such a family states -//! two authorities for one capacity, `crate::types` names it as a declaration -//! defect no bound can see, and it is outside this population by construction — -//! the denominator here is families with no compile-time magnitude. Widening the -//! claim to cover it is a separate law with its own name. +//! **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}; @@ -112,7 +140,9 @@ const RUNTIME_LADDER: &str = "EvidenceSelectedLimit"; 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. +/// 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 /// @@ -162,13 +192,19 @@ struct PositivityVerdict { /// /// 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 family -/// declaring both ladders is representable here, and a pair of booleans would -/// have read as a state machine nobody named. +/// `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 { - /// Where `impl … Limit for F` was read, where a source declares it. - declared_at: Option, + /// 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, } @@ -178,8 +214,8 @@ struct FamilyFacts { struct Reading { /// Every limit family the machine declares, by name. families: BTreeMap, - /// Every family bounding an inhabitant-promising seat, and the first - /// declaration that seats it. + /// Every family bounding an inhabitant-promising seat, and the first site + /// that seats it. seated: BTreeMap, } @@ -198,11 +234,28 @@ fn positivity_verdict(sources: &[(&CanonicalPath, &syn::File)]) -> PositivityVer 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 else { + let Some(declared_at) = facts.declared_at.first() else { continue; }; if facts.ladders.contains(DECLARED_LADDER) { @@ -237,6 +290,12 @@ fn read_sources(sources: &[(&CanonicalPath, &syn::File)]) -> 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 @@ -270,7 +329,7 @@ fn read_items(path: &str, items: &[syn::Item], reading: &mut Reading) { } else if let syn::Item::Mod(module) = item && let Some((_, inner)) = &module.content { - read_items(path, inner, reading); + read_items(&format!("{path}::{}", module.ident), inner, reading); } } } @@ -305,8 +364,8 @@ fn record_ladder(path: &str, contract: &str, family: &str, reading: &mut Reading return; } let facts = reading.families.entry(family.to_owned()).or_default(); - if contract == LIMIT_CONTRACT && facts.declared_at.is_none() { - facts.declared_at = Some(path.to_owned()); + if contract == LIMIT_CONTRACT { + facts.declared_at.insert(path.to_owned()); } facts.ladders.insert(contract.to_owned()); } @@ -529,7 +588,9 @@ mod tests { /// inhabitant. const WITNESSED: &str = "\ pub struct DemoIssueLimit;\n\ - impl Limit for 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\ @@ -539,7 +600,9 @@ mod tests { /// promises an inhabitant and nothing can establish the promise. const OFF_THE_LADDER: &str = "\ pub struct DemoIssueLimit;\n\ - impl Limit for DemoIssueLimit {}\n\ + impl Limit for DemoIssueLimit {\n\ + \x20 type Authority = UnstatedMagnitude;\n\ + }\n\ pub struct DemoRefusal {\n\ \x20 body: AdmittedPrefix,\n\ }\n"; @@ -598,7 +661,9 @@ mod tests { 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\ + impl Limit for DemoIssueLimit {\n\ + \x20 type Authority = DeclaredMagnitude;\n\ + }\n\ impl ConstLimit for DemoIssueLimit {\n\ \x20 const MAX: usize = 32;\n\ }\n\ @@ -709,6 +774,116 @@ mod tests { 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. From 44e6b82f623bbf6f31ce510f501d0c1a5052f882 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Fri, 14 Aug 2026 09:38:59 -0400 Subject: [PATCH 9/9] Name the law that moved the pinned denominator: 183 becomes 184 --- xtask/src/checks/obligations.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/xtask/src/checks/obligations.rs b/xtask/src/checks/obligations.rs index aae07c2..5a13926 100644 --- a/xtask/src/checks/obligations.rs +++ b/xtask/src/checks/obligations.rs @@ -4077,7 +4077,7 @@ fn a_law_nobody_declared() {} /// /// # The denominator is PINNED, because a reader was replaced /// - /// ONE HUNDRED AND EIGHTY-THREE laws, and the number is written here rather + /// 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 @@ -4086,6 +4086,14 @@ fn a_law_nobody_declared() {} /// 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() -> Result<(), String> { let snapshot = repository_snapshot()?; @@ -4097,7 +4105,7 @@ fn a_law_nobody_declared() {} let existing = declared_laws(&laws); assert_eq!( existing.len(), - 183, + 184, "laws.rs declares {} laws", existing.len() );