From 9b39e26b319e0c5cf09a3e95084543a539ec2b08 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 04:03:26 +0200 Subject: [PATCH 1/7] docs: correct every claim that had drifted from the code --- CONTRIBUTING.md | 11 +- README.md | 5 +- crates/dpp-domain/README.md | 17 +- crates/dpp-domain/src/lib.rs | 7 +- crates/dpp-domain/src/ports/archive.rs | 13 ++ crates/dpp-tests/tests/domain_concerns.rs | 13 +- .../dpp-tests/tests/mod_rs_is_pure_index.rs | 7 +- docs/README.md | 6 +- docs/architecture/ARCHITECTURE.md | 5 +- docs/architecture/DATA-MODEL.md | 167 +++++++++++++++--- docs/architecture/EFFECTIVE-DATES.md | 16 +- docs/architecture/OVERVIEW.md | 11 +- docs/architecture/PLUGIN-HOST.md | 14 +- docs/architecture/PORTS.md | 2 +- docs/governance/VERSIONING.md | 7 +- 15 files changed, 247 insertions(+), 54 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64f17a2f..97c87a42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,14 +56,14 @@ That's it. Everything compiles and tests with nothing else running. ``` dpp-core/ - Cargo.toml # Workspace root — 9 member crates + benches + Cargo.toml # Workspace root — 12 member crates + benches LICENSE # Apache-2.0 crates/ dpp-domain/ # Domain types, port traits, ProductGroupCatalog, VersionedSchemaRegistry schemas/ # Versioned JSON schemas, embedded via include_str! (the product): - # aluminium, battery (v1+v2), construction, detergent, - # electronics, furniture, steel, textile (v1+v2), - # unsold-goods, toy, tyre — 11 product groups + # aluminium, battery, construction, detergent, electronics, + # furniture, mattress, steel, textile, toy, tyre, + # unsold-goods — 12 product groups, 30 versions dpp-rules/ # Pure no_std, zero-dep cross-field regulatory rules dpp-crypto/ # Ed25519, AES-GCM, JWS, encrypted keystore dpp-vc/ # W3C VCs, did:web, status lists, LocalIdentityService, JSON-LD @@ -73,7 +73,8 @@ dpp-core/ dpp-plugin-traits/ # Wasm plugin ABI (no_std) dpp-plugin-sdk/ # Guest-side SDK: export_plugin! macro + Validator dpp-registry/ # EU registry interface types (wasm32-safe) - dpp-tests/ # Cross-crate integration tests (publish = false) + dpp-vocab/ # External vocabulary authorities, one file per authority + dpp-tests/ # Cross-crate integration tests and structural tripwires (publish = false) benches/ # Criterion benchmarks (workspace member) plugins/ # 10 product group Wasm plugins (excluded from workspace) product-group-battery/ product-group-textile/ product-group-steel/ product-group-electronics/ diff --git a/README.md b/README.md index 192fd390..07eb6d5d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Succeeds with zero infrastructure running. No DB, no Redis, no env vars. If it n dpp-core/ crates/ dpp-domain .......... Domain types, port traits, VersionedSchemaRegistry, JSON Schema validation - schemas/ .......... Versioned JSON Schemas for 11 product groups (battery, textile, electronics, …), embedded via include_str! + schemas/ .......... Versioned JSON Schemas for 12 product groups (battery, textile, electronics, …), embedded via include_str! dpp-crypto .......... Ed25519 keys, AES-256-GCM, JWS sign/verify, JAdES dpp-digital-link .... GS1 Digital Link parser and link-type negotiation dpp-aas ............. Asset Administration Shell (AAS) shells and submodels @@ -51,7 +51,8 @@ dpp-core/ dpp-rules ........... Pure no_std cross-field regulatory rules, shared by dpp-domain and plugins dpp-registry ........ EU Central Registry interface types (wasm32-safe) dpp-calc ............ EU-methodology calculators (CO2e, repairability), pure functions - dpp-tests ........... Cross-crate integration tests (domain, crypto, digital-link, aas, vc) + dpp-vocab ........... External vocabulary authorities, one file per authority, with what we verified + dpp-tests ........... Cross-crate integration tests and the structural tripwires plugins/ .............. 10 Wasm product group plugins (wasm32-wasip1, excluded from workspace) ``` diff --git a/crates/dpp-domain/README.md b/crates/dpp-domain/README.md index dcac8b82..fdcd5f27 100644 --- a/crates/dpp-domain/README.md +++ b/crates/dpp-domain/README.md @@ -7,12 +7,18 @@ Core domain types, port traits, and schema validation for the [Odal Node](https://odal-node.io) Digital Product Passport system. -This is the foundational crate. All other `dpp-*` crates depend on it. -It contains everything that changes when EU regulations change — and nothing else. +This is the foundational crate: any other `dpp-*` crate may depend on it, and +several do. It contains everything that changes when EU regulations change — and +nothing else. ## When to use this crate - You need the DPP data model: `Passport`, `ProductGroupData`, `TransferChain`. +- You need to know **what law reaches a product group**: `InstrumentCatalog` + holds one manifest per act, with a `PassportObligation` and one + `InstrumentBinding` per (act, product group) pair. Obligations accumulate — + ESPR Art. 5(7) lets acts overlap and sets no precedence rule between them — so + this answers with a *set*, and a determination is always made under a named act. - You are implementing a platform adapter (database, HTTP layer) and need the port trait interfaces: `PassportRepository`, `IdentityPort`, `PluginHost`, etc. - You want to validate passport data against embedded JSON schemas. @@ -25,8 +31,11 @@ use dpp_domain::catalog::ProductGroupCatalog; use dpp_domain::Audience; use serde_json::json; -// Product group metadata is data, not code: regime, status and retention all come -// from the catalog manifests. +// Product groups are data, not code — one embedded manifest each. The descriptor +// carries identity, scope, schema versions, disclosure and plugin binding, and no +// law at all: status, legal basis, passport obligation, dates, retention and +// granularity are properties of an (act, product group) pair and live on +// `InstrumentBinding` in `catalog::InstrumentCatalog`. let catalog = ProductGroupCatalog::new(); let battery = catalog.get("battery").expect("battery is in the catalog"); assert_eq!(battery.key, "battery"); diff --git a/crates/dpp-domain/src/lib.rs b/crates/dpp-domain/src/lib.rs index 5ee088f9..7d8d1d87 100644 --- a/crates/dpp-domain/src/lib.rs +++ b/crates/dpp-domain/src/lib.rs @@ -1,7 +1,10 @@ //! `dpp-domain` — EU Digital Product Passport domain types and port traits. //! -//! This crate is the dependency root of the DPP workspace. Every other crate -//! depends on this one. It depends only on `dpp-rules` (pure regulatory rules). +//! The dependency root of the DPP workspace: any crate here may depend on it, +//! and it depends only on `dpp-rules` (pure regulatory rules). Not every crate +//! does — `dpp-rules`, `dpp-crypto`, `dpp-calc`, `dpp-vocab`, `dpp-plugin-traits` +//! and `dpp-plugin-sdk` stand on their own, which is why a Wasm product-group +//! plugin never links this crate. //! //! No I/O, no async, no HTTP, no database drivers — pure domain logic only. diff --git a/crates/dpp-domain/src/ports/archive.rs b/crates/dpp-domain/src/ports/archive.rs index efce7702..682681c1 100644 --- a/crates/dpp-domain/src/ports/archive.rs +++ b/crates/dpp-domain/src/ports/archive.rs @@ -5,6 +5,19 @@ //! withdrawal by the economic operator. A copy of the DPP must be hosted by //! an independent third-party digital service provider. //! +//! The obligation is **Art. 10(4)**: the economic operator "shall make available +//! a back-up copy of the digital product passport through a digital product +//! passport service provider", which **Art. 2(32)** defines as "an independent +//! third-party authorised by the economic operator". The period is **Annex +//! III(i)** — "at least the expected lifetime of a specific product" — delegated +//! per product group. **Annex III(l)** makes the provider's reference a passport +//! data element. +//! +//! Two consequences worth stating, because both have been got wrong before. +//! *Independent third party* means an operator's own storage does not discharge +//! this, however durable. And the article is **not Art. 13**, which establishes +//! the registry and is a different duty entirely. +//! //! This port defines the contract that platform adapters implement to //! replicate published passport data to an independent archive. diff --git a/crates/dpp-tests/tests/domain_concerns.rs b/crates/dpp-tests/tests/domain_concerns.rs index f023a025..23880604 100644 --- a/crates/dpp-tests/tests/domain_concerns.rs +++ b/crates/dpp-tests/tests/domain_concerns.rs @@ -1,10 +1,15 @@ //! Drift tripwire: the concern inventory in `docs/architecture/ARCHITECTURE.md` //! must exactly match the public modules declared in `dpp-domain`'s `lib.rs`. //! -//! `dpp-domain` is the largest crate in the workspace — roughly three and a half -//! times the next one — and is the hub every other crate depends on. That makes -//! it the crate most able to absorb a new capability without anyone noticing, -//! and "it has room" is exactly how a hub becomes a bag. +//! `dpp-domain` is by a wide margin the largest crate in the workspace, and the +//! hub of the crates that depend on anything at all. That makes it the crate +//! most able to absorb a new capability without anyone noticing, and "it has +//! room" is exactly how a hub becomes a bag. +//! +//! Deliberately no ratio here. An earlier version of this comment said "roughly +//! three and a half times the next one", which had drifted by the time anyone +//! checked — in a file whose whole subject is that prose counts go stale with +//! nothing watching them. //! //! The rule this enforces is that **growing a top-level concern is a deliberate //! act**: adding one means editing `lib.rs` *and* the inventory, which is the diff --git a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs index d0d1cc01..b93d6922 100644 --- a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs +++ b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs @@ -1,10 +1,13 @@ //! Drift tripwire: no `mod.rs` in a published crate may declare a public item. //! -//! Mechanical enforcement of the re-layout's rule 2 (`docs/refactor-2026-07/core/00-INDEX.md`): -//! a `mod.rs` is a pure index — module docs, `pub use` re-exports, and +//! A `mod.rs` is a pure index — module docs, `pub use` re-exports, and //! submodule declarations only. Zero `pub struct` / `pub enum` / `pub trait` / //! `pub fn` definitions. This keeps every `mod.rs` skimmable and forces new //! types into their own named file as the crate grows. +//! +//! One of several code-layout rules this repository holds itself to, and for a +//! long time the only one with a test behind it — which is why it is also the +//! only one that never drifted. use std::fs; use std::path::{Path, PathBuf}; diff --git a/docs/README.md b/docs/README.md index 89c57e79..f3a69f67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,9 @@ This folder documents **the standard, not the product**: what a Digital Product | "How do product group plugins run safely?" | [architecture/PLUGIN-HOST.md](architecture/PLUGIN-HOST.md) | | "How do passports link to their components and predecessors?" | [architecture/PRODUCT-LINEAGE.md](architecture/PRODUCT-LINEAGE.md) — design proposal, not yet implemented | | "Where does code meet regulation, formally?" | [regulatory/CONFORMITY.md](regulatory/CONFORMITY.md) — written for assessment bodies | -| "How are releases, versions, and contributions governed?" | [governance/](governance/) — VERSIONING, RELEASE, CONTRIBUTING, CHANGELOG | +| "Which acts reach a product group, and from when?" | [architecture/DATA-MODEL.md](architecture/DATA-MODEL.md) §3.5 — the instrument catalog and why applicable law is a *set* | +| "Why is a date pending rather than computed?" | [architecture/EFFECTIVE-DATES.md](architecture/EFFECTIVE-DATES.md) | +| "How are releases, versions, and contributions governed?" | [governance/](governance/) — VERSIONING, RELEASE, DEVELOPMENT, GIT-STRATEGY (CONTRIBUTING and CHANGELOG are at the repo root) | ## The three ideas that explain everything else @@ -25,4 +27,4 @@ This folder documents **the standard, not the product**: what a Digital Product **The compiler enforces the boundary.** Core builds with zero infrastructure (`cargo build --workspace`, no DB, no env). Anything that needs a database or an HTTP client lives across the seam in the engine. The port traits in `dpp-domain/src/ports/` *are* the boundary — the module is authoritative, prose never quotes a hardcoded count. -**Honesty is a feature.** Placeholder implementations (the Ghost family) are clearly marked, regulatory citations that can't be pinned to the Official Journal are flagged rather than asserted, and provisional product groups can never emit a binding compliance verdict. +**Honesty is a feature.** Placeholder implementations (the Ghost family) are clearly marked, and regulatory citations that can't be pinned to the Official Journal are flagged rather than asserted. A binding compliance verdict requires an act that is actually in force *and* actually imposes a passport — two separate questions, asked separately. An act can bind today and require no passport (ESPR Arts. 24–25), or have its passport duty discharged by another system entirely (Art. 9(4)(b), EPREL). Conflating those is how a node comes to assert compliance against an obligation that does not exist. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ef6f6755..97ebdc48 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -62,7 +62,8 @@ dpp-tests — cross-crate integration tests (not published) ## dpp-domain — The Domain -The dependency root. Every other crate may depend on it; it depends on nothing internal. +The dependency root. Every other crate may depend on it; it depends on nothing +internal but `dpp-rules`. Six of them do not — see [VERSIONING.md](../governance/VERSIONING.md). ### Top-level concerns @@ -74,7 +75,7 @@ compares this list against `lib.rs` and fails the build in either direction. | Concern | What it holds | |---|---| | `access` | The per-field disclosure contract — `ProductGroupAccessPolicy`, `filter_by_audience` | -| `catalog` | Product group manifests: regulatory status, regime, retention, schema versions | +| `catalog` | Two catalogs. `ProductGroupCatalog` — identity, scope, schema versions, disclosure classes, plugin binding; it carries **no law**. `InstrumentCatalog` — the acts, their `PassportObligation`, and one `InstrumentBinding` per (act, product group) pair, which is where status, legal basis, dates, retention and granularity live | | `compliance` | The Apache-2.0 passthrough registry and its per-product group strategies | | `domain` | The passport aggregate, product group data, lifecycle, transfer, validation | | `ports` | The core↔platform trait boundary (see [PORTS.md](PORTS.md)) | diff --git a/docs/architecture/DATA-MODEL.md b/docs/architecture/DATA-MODEL.md index bb2e2b78..ab11d805 100644 --- a/docs/architecture/DATA-MODEL.md +++ b/docs/architecture/DATA-MODEL.md @@ -48,29 +48,50 @@ Custom serde: domain `Published` serialises to wire `"active"` (and back). This ### 3.1 Base Passport (`Passport` struct) -All DPPs — regardless of product group — carry these fields. Source: `dpp-domain/src/domain/passport.rs`. +All DPPs — regardless of product group — carry these fields. Source: +`dpp-domain/src/domain/passport/passport.rs`. + +**`PASSPORT_WIRE_KEYS` in that file is the authority**, not this table. It is a +`const` the tests assert against, so it cannot drift; the table below is a +reading aid and is only as fresh as its last edit. If the two disagree, the +constant is right. | Field | Rust Type | JSON name | Description | |---|---|---|---| -| `id` | `PassportId` (UUID v4) | `"id"` | Unique passport identifier | +| `id` | `PassportId` | `"id"` | Unique passport identifier | | `batch_id` | `Option` | `"batchId"` | Optional batch or lot identifier (ESPR Art. 9) | | `product_name` | `String` | `"productName"` | Human-readable product name (ESPR Art. 9) | -| `product group` | `ProductGroup` enum | `"product group"` | EU ESPR product group — the **dispatch key** (`battery`, `textile`, …). Selects schema + plugin. | +| `product_group` | `ProductGroup` | `"productGroup"` | EU ESPR product group — the **dispatch key** (`battery`, `textile`, …). Selects schema + plugin. | +| `applicable_instruments` | `Vec` | `"applicableInstruments"` | The acts that applied at issuance, **recorded not computed**, and immutable thereafter (see §3.5) | +| `granularity` | `Option` | `"granularity"` | Model / batch / item level, an ESPR Art. 9(2)(d) delegated-act decision. `None` where no act has fixed one — the position of every ESPR product group today | | `manufacturer` | `ManufacturerInfo` | `"manufacturer"` | Nested: name, address, optional did:web URL | | `materials` | `Vec` | `"materials"` | Bill of materials entries | -| `co2e_per_unit` | `Option` | `"co2ePerUnit"` | CO₂e per unit in kg — may be set by compliance engine | -| `repairability_score` | `Option` | `"repairabilityScore"` | Repairability score (0.0–10.0) | +| `co2e_per_unit` | `Option` | `"co2ePerUnit"` | CO₂e per unit — may be set by the compliance engine | +| `repairability_score` | `Option` | `"repairabilityScore"` | Structured `{overall, criteria}`, not a bare number | +| `compliance_result` | `Option` | `"complianceResult"` | Outcome of the last determination | +| `lint_result` | `Option` | `"lintResult"` | Advisory findings. `None` until a lint pass has run | | `product_group_data` | `Option` | `"productGroupData"` | Typed product-group-specific data (tagged enum) | | `status` | `PassportStatus` | `"status"` | Lifecycle state (see §2) | | `qr_code_url` | `Option` | `"qrCodeUrl"` | Public URL for QR code resolution | -| `jws_signature` | `Option` | `"jwsSignature"` | Compact JWS over canonical payload (Ed25519) | +| `jws_signature` | `Option` | `"jwsSignature"` | Compact JWS over the **full** canonical payload (Ed25519) | +| `public_jws_signature` | `Option` | `"publicJwsSignature"` | JWS over the **public projection** — a different redaction, so never interchangeable with the above | +| `disclosure_signatures` | `BTreeMap` | `"disclosureSignatures"` | Per-audience signatures, keyed by audience | | `created_at` | `DateTime` | `"createdAt"` | Record creation timestamp | | `updated_at` | `DateTime` | `"updatedAt"` | Last modification timestamp | -| `published_at` | `Option` | `"publishedAt"` | First publish timestamp | +| `published_at` | `Option>` | `"publishedAt"` | First publish timestamp | +| `placed_on_market_date` | `Option` | `"placedOnMarketDate"` | Fixes which law governs. Never defaulted to today — a determination depending on an absent value has no answer, and saying so is the answer | | `schema_version` | `String` | `"schemaVersion"` | Semver of the product group schema used for validation | | `retention_locked` | `bool` | `"retentionLocked"` | Set permanently on first publish; prevents deletion | -| `parent_passport_ref` | `Option` | `"parentPassportRef"` | Cross-operator predecessor this record derives from (second-life lineage). Omitted when absent. | -| `component_refs` | `Vec` | `"componentRefs"` | Cross-operator references to constituent passports — the bill of materials. Omitted when empty. | +| `version` | `u32` | `"version"` | Monotonic counter; `1` on first publish | +| `supersedes_id` | `Option` | `"supersedesId"` | The passport this record supersedes. `None` for first versions | +| `parent_passport_ref` | `Option` | `"parentPassportRef"` | Cross-operator predecessor (second-life lineage). Omitted when absent | +| `component_refs` | `Vec` | `"componentRefs"` | Cross-operator references to constituent passports. Omitted when empty | +| `retention_until` | `Option>` | `"retentionUntil"` | Computed at publish from the instrument bindings' retention fold | +| `product_id` | `Option` | `"productId"` | Opaque link to an internal product-template record. **Not a legal identifier** | +| `commodity_code` | `Option` | `"commodityCode"` | CN code. Absent rather than guessed — a registry requiring it refuses the registration instead of this node inventing a classification | +| `operator_identifier` | `Option` | `"operatorIdentifier"` | The operator **at signing time**, covered by the signature. Reading it as "who is responsible today" is wrong for any passport that has changed hands | +| `facility` | `Option` | `"facility"` | A snapshot, so a retired facility never orphans a published passport | +| `seal` | `Option` | `"seal"` | eIDAS qualified electronic seal over `jws_signature` | ### 3.2 ManufacturerInfo @@ -102,7 +123,7 @@ Elements of `Passport.materials` — bill of materials entries. | `battery` | `battery_type` | Battery Reg. 2023/1542 Art. 1(3) — closed, five categories, required | | `steel` | `product_category` | `"flat"` / `"long"` / … | | `electronics` | `product_category` | `"smartphone"` / `"other-mobile-phone"` / `"cordless-phone"` / `"tablet"` — closed, Reg. (EU) 2023/1670 Art. 1(1) | -| `unsold-goods` | `product_category` | `"apparel"` / `"footwear"` / … | +| `unsold-goods` | `product_category` | `"apparel"` / `"footwear"` / … — **ours, and superseded.** Impl. Reg. (EU) 2026/2 Art. 3 delimits by CN code. See §4.4 | | `furniture` | `product_type` | — | | `tyre` | `tyre_class` | `"C1"` / … | @@ -111,7 +132,57 @@ Elements of `Passport.materials` — bill of materials entries. 2. These fields are plain product group data. A plugin *may* read one to choose an internal rule path, but it does not change which plugin runs. 3. The names and shapes are deliberately uneven — they track what each product group's own act defines, not a normalised cross-product group vocabulary. Only `battery_type` is a closed, required, typed enum; that follows from Art. 1(3) being a named enumeration in law, which is not true of the others. -`Passport::validate()` enforces that `product group` matches `product_group_data`'s product group when the latter is present. +`Passport::validate()` enforces that `productGroup` matches `productGroupData`'s product group when the latter is present. + +### 3.5 Applicable instruments — the law is not on the product group + +A product group does **not** determine the law that governs it, and the model no +longer pretends otherwise. `ProductGroupDescriptor` carries identity, scope, +schema versions, disclosure classes and a plugin binding — and no legal fields at +all. Status, legal basis, passport obligation, dates, retention and granularity +are properties of an **(act, product group) pair** and live on `InstrumentBinding` +in the second catalog. See [ARCHITECTURE.md](ARCHITECTURE.md) §`dpp-domain`. + +Three records replace what used to be one: + +| Record | Answers | +|---|---| +| `Instrument` | *What is this act?* — id, CELEX, `InstrumentKind` (Framework · Delegated · Direct · Adjacent), `InstrumentStatus`, and its `PassportObligation` | +| `ProductGroup­Descriptor` | *What is this group and how do we serve it?* — key, title, schema versions, product categories, disclosure, plugin | +| `InstrumentBinding` | *What does this act do to this group?* — one per pair: status, legal basis, dates, retention, granularity | + +**Why a set and not a field.** ESPR **Art. 5(7)** lets one delegated act cover +many product groups and lets a group-specific act supplement a horizontal one, +and the Regulation contains **no precedence rule anywhere** — so overlapping acts +*accumulate*. Applicable instruments are therefore a set, and the governing +requirement is the union. + +**Folds are unions, never precedence.** Retention is the **maximum** (periods are +floors). The passport due date is the **earliest** (once the first act's date +arrives, a passport is owed). Granularity is the **most granular** (an item-level +record satisfies a model-level requirement). Provenance folds too: a compound +retention figure is `Sourced` only if *every* contributing figure is. + +**"May this bind?" is never folded to a boolean.** `InstrumentCatalog::determinable_for` +returns the (instrument, binding) pairs, not a yes/no, because a determination is +always made *under a named act* — a caller that only learns "yes" cannot say what +it is asserting against. That is exactly how a determination once came to be +emitted against an obligation that did not exist. + +**`PassportObligation` is a three-way answer**, not an optional date: +`Required { from }` · `NotRequired` · `DisplacedBy { system, basis }`. The third +is ESPR **Art. 9(4)(b)** — an act whose information duty is discharged through +another system, e.g. EPREL. Without it, an act that creates real, live obligations +but *no passport* could only be recorded as "no date yet", which reads as "a +passport is coming". Determinability and passport duty are **independent +predicates**: ESPR Arts. 24–25 bind today and impose no passport at all. + +**Recorded, not computed.** `applicable_instruments` is written at issuance and is +immutable in both senses — it is in `PROTECTED_PATCH_FIELDS` and absent from +`RETENTION_MUTABLE_FIELDS`. Corrections go by supersession. `InstrumentRef` also +carries a `RecordedBasis` of `Catalog` or `Operator`; `Operator` is not a +fallback, it is the case where an act reaches a product whose group no catalog +models and the operator must assert it. --- @@ -197,17 +268,59 @@ Source: ESPR Working Group on Textiles. Delegated act adoption anticipated ~Q2 2 Schemas: `schemas/textile/v1.0.0.json`, `schemas/textile/v1.1.0.json` -### 4.3 Steel Product group — PROVISIONAL - -CBAM-aligned. Schema at `schemas/steel/v1.0.0.json`. - -### 4.4 Unsold Goods (`UnsoldGoodsReport`) +### 4.3 The rest of the catalog -ESPR Art. 25 / Annex VII destruction-ban reporting for unsold consumer products. Schema at `schemas/unsold-goods/v1.0.0.json`. +Every product group has a typed variant and at least one schema. Only `battery` +and `textile` have models deep enough to warrant their own section above; the +others are listed here rather than each getting a stub. -### 4.5 Electronics, Other - -`ProductGroupData::Electronics` and `ProductGroupData::Other` variants exist but have no product-group-specific struct yet. +| Product group | Type | Current schema | Note | +|---|---|---|---| +| `aluminium` | `AluminiumData` | v1.1.0 | Carbon intensity, CBAM-aligned. Intermediate product | +| `construction` | `ConstructionData` | v1.1.0 | CPR (EU) 2024/3110. **Wrong axis** — the CPR defines product *family* → *category* → *type* and uses "product group" zero times. Re-homing needs CPR Annex VII read first | +| `detergent` | `DetergentData` | v1.1.0 | Reg. (EU) 2026/405. Surfactant bands per its Annex VII | +| `electronics` | `ElectronicsData` | v1.2.0 | Narrowed to the four device types Reg. (EU) 2023/1670 Art. 1(1) enumerates. **No passport obligation** — its instruments are EPREL-displaced | +| `furniture` | `FurnitureData` | v1.2.0 | v1.2.0 drops `mattress` from `productType`; v1.1.0 is kept for stored documents | +| `mattress` | `MattressData` | v1.0.0 | Split out of furniture: the working plan makes Mattresses a **separate** product group. Fields are furniture's minus `productType` and **nothing added** — no delegated act exists, so any mattress-specific field would be invented. No Wasm plugin | +| `steel` | `SteelData` | v1.1.0 | CBAM-aligned. Intermediate product, earliest indicative act of any group | +| `toy` | `ToyData` | v1.1.0 | Reg. (EU) 2025/2509 | +| `tyre` | `TyreData` | v1.0.0 | | +| `unsold-goods` | `UnsoldGoodsReport` | v1.0.0 | See §4.4 — **not a product group** and its model is out of date | + +`ProductGroupData::Other` keeps the tag and payload of a product group this build +has no typed variant for, verbatim, so an unknown group round-trips rather than +being dropped. + +### 4.4 Unsold goods is not a product group, and its model predates its law + +ESPR Arts. 24–25 / Annex VII. It occupies a catalog slot for implementation +convenience and borrows textile's plugin, but it is a **horizontal obligation on +an operator over a financial year**, not a product placed on the market. It +carries `PassportObligation::NotRequired`: the duty is real and binding today, +and there is no passport anywhere in Arts. 24–25. + +⚠️ **`UnsoldGoodsReport` does not match the adopted format.** Two acts now govern +this, both adopted 9 February 2026 and neither reflected in the type: + +- **Commission Implementing Regulation (EU) 2026/2** (CELEX `32026R0002`), under + Art. 24(3) — Art. 2(1) requires the disclosure to comply with the format in its + **Annex I**, and Art. 3 delimits categories by **CN code**, first two digits + (four for its Annex II list). +- **Commission Delegated Regulation (EU) 2026/296** (CELEX `32026R0296`), under + Art. 25(5) — the **closed list of derogations** from the destruction ban. + Annex I note (h) of 2026/2 points its reason vocabulary at that list. + +Known divergences: the period is a **financial year** with start and end dates, +not a free-text quarter; categories are **CN codes**, not names like `"apparel"`; +the disclosure needs a legal-entity header and a standalone-vs-consolidated flag; +unit counts and a packaging-included flag are absent; waste treatment is a +**percentage split** across preparing-for-reuse, recycling, other recovery, +disposal, total destruction and unknown — where *destruction is the sum of +recycling, other recovery and disposal* — not a single destination; and the two +"measures taken / planned to prevent destruction" fields have no representation. +`UnsoldGoodsReason`'s variants are ours, not the Regulation's. + +Reuse `CommodityCode` for the CN axis when this is rebuilt. --- @@ -258,7 +371,13 @@ Schemas follow semver. The `VersionedSchemaRegistry` in `dpp-domain` discovers a | Minor (`1.x.0`) | New optional fields; provisional -> strict | Yes | | Major (`x.0.0`) | Field renamed, type changed, or removed | No | -Current schemas: 28 embedded versions across 11 product groups — see -`crates/dpp-domain/src/schemas/embedded.rs` for the registered list. Every one -is reachable at runtime; a passport is validated against the version it -declares, not against the newest. +The registered list is `crates/dpp-domain/src/schemas/embedded.rs`, and it is the +only place worth reading for what exists — every product group carries at least +one version and several carry three. Every registered version is reachable at +runtime; a passport is validated against the version it declares, not against +the newest. + +No count is written here on purpose. This paragraph used to open "28 embedded +versions across 11 product groups", which was wrong within a day of `mattress` +landing — nine lines below the advice in §"Version bump" that a count is the part +that goes stale while every claim around it stays checkable. diff --git a/docs/architecture/EFFECTIVE-DATES.md b/docs/architecture/EFFECTIVE-DATES.md index 035d6790..315cfb91 100644 --- a/docs/architecture/EFFECTIVE-DATES.md +++ b/docs/architecture/EFFECTIVE-DATES.md @@ -146,13 +146,27 @@ Conditional { struct Trigger { empowerment: &'static str, - kind: InstrumentKind, // Delegated | Implementing + kind: TriggerKind, // Delegated | Implementing adoption_deadline: Option, /// `None` until the act is adopted and its OJ entry-into-force date is read. entered_into_force: Option, } ``` +⚠️ **`TriggerKind` above is a sketch and is deliberately *not* named +`InstrumentKind`.** A real `InstrumentKind` now exists in +`dpp-domain::catalog` with the variants **Framework · Delegated · Direct · +Adjacent** — it classifies what kind of act an instrument is, not what starts a +date offset. The two answer different questions and must not be conflated when +this gets built; reuse the real one only if its variants genuinely fit. + +**Also reconsider the premise when this is picked up.** "No relevant instrument +has entered into force" was true when written and is no longer true in general: +Impl. Reg. (EU) 2026/2 and Del. Reg. (EU) 2026/296 were adopted 9 February 2026 +under ESPR Arts. 24(3) and 25(5). Neither is a *battery* trigger, so the +deferral stands on its own terms — but the sentence should be read as scoped to +Reg. (EU) 2023/1542, which is what it meant. + `Conditional` resolves to `InForce` once every trigger has an `entered_into_force`, and behaves like `Pending` until then — so `Assessability::Undetermined` remains the answer without any caller changing. diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index e50bd51d..f07c2c06 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -33,10 +33,19 @@ Draft --> Active (Published) --> Suspended --> Archived |---|---|---| | Draft -> Active | All mandatory fields present and valid | Signed with issuer's Ed25519 key; JWS produced | | Active -> Suspended | Reason required (recall, investigation, etc.) | Signature retained; resolver returns 410 | -| Any -> Archived | Irreversible | Retained for regulatory lifecycle (10-20 years) | +| Any -> Archived | Irreversible | Retained until `retentionUntil`, computed at publish from the instrument bindings (see below) | Every transition is recorded by the platform layer (audit logging is a platform concern, not a domain concern). +**On retention.** The period is not a constant in this document or anywhere else +in prose. It is the **maximum** across every recorded `InstrumentBinding` that +reaches the product group — periods are floors, so a record kept long enough for +the longest satisfies them all — and it carries the provenance of the figure that +produced it: the fold is `Sourced` only when *every* contributing figure is. +Every figure recorded today happens to be 10 years, several of them `Assumed` +rather than read from an adopted text, which is exactly why the number is not +written down here as though it were settled. + --- ## 3. Data Flow (Conceptual) diff --git a/docs/architecture/PLUGIN-HOST.md b/docs/architecture/PLUGIN-HOST.md index e50a6d22..5ba064c0 100644 --- a/docs/architecture/PLUGIN-HOST.md +++ b/docs/architecture/PLUGIN-HOST.md @@ -105,8 +105,18 @@ All ten plugins run on the SDK (`dpp-plugin-sdk` + `export_plugin!`): |---|---|---|---| | `product-group-battery` | battery | `schemas/battery/v{1.0.0, 2.0.0 … 2.6.0}.json` | SDK (`DppProductGroupPlugin`) | | `product-group-textile` | textile, unsold-goods | `schemas/textile/*`, `unsold-goods/*` | SDK (`DppProductGroupPlugin`) | -| `product-group-steel` | steel | `schemas/steel/v1.0.0.json` | SDK (`DppProductGroupPlugin`) | -| `product-group-electronics`, `-construction`, `-tyre`, `-toy`, `-aluminium`, `-furniture`, `-detergent` | resp. | `schemas/{product-group}/v1.0.0.json` | SDK (`DppProductGroupPlugin`) | +| `product-group-steel` | steel | `schemas/steel/*` | SDK (`DppProductGroupPlugin`) | +| `product-group-electronics`, `-construction`, `-tyre`, `-toy`, `-aluminium`, `-furniture`, `-detergent` | resp. | `schemas/{product-group}/*` | SDK (`DppProductGroupPlugin`) | + +Schema versions are deliberately not enumerated here — several of these groups +are past v1.0.0 and the list went stale the first time one moved. A plugin +validates against whatever versions its product group has registered in +`dpp-domain::schemas::embedded`; that file is the authority. + +**`mattress` has no plugin** — twelve product groups, ten plugins. It was split +out of `furniture` because the ESPR working plan makes Mattresses a separate +product group, and no delegated act exists for it, so there is nothing for a +plugin to check that furniture's does not already cover. Plugins are standalone Rust crates excluded from the workspace. Each depends on `dpp-plugin-sdk` (which re-exports `dpp-plugin-traits`), implements `DppProductGroupPlugin`, and calls `export_plugin!` once — none hand-roll the ABI. **`product-group-battery` is the reference implementation.** diff --git a/docs/architecture/PORTS.md b/docs/architecture/PORTS.md index 8fc0b3eb..1933fd14 100644 --- a/docs/architecture/PORTS.md +++ b/docs/architecture/PORTS.md @@ -10,7 +10,7 @@ drifts the moment another port lands). CI enforces agreement: the test | Module | Trait(s) | Concern | |---|---|---| -| `archive` | `ArchivePort` | Immutable third-party archival with retention guarantees (ESPR Art. 13). | +| `archive` | `ArchivePort` | Immutable third-party archival with retention guarantees (ESPR **Art. 10(4)** back-up copy, **Art. 2(32)** independent third party, **Annex III(i)** availability period — *not* Art. 13, which is the registry). | | `compliance` | `ComplianceRegistry`, `ComplianceStrategy` | Product group dispatch + per-product group compliance strategy (**two traits**). | | `identity_port` | `IdentityPort` | Operator-key sign/verify (Ed25519/JWS). | | `passport_repo` | `PassportRepository` | Passport persistence. | diff --git a/docs/governance/VERSIONING.md b/docs/governance/VERSIONING.md index 854a9fff..f9ebf453 100644 --- a/docs/governance/VERSIONING.md +++ b/docs/governance/VERSIONING.md @@ -35,8 +35,11 @@ All workspace crates share a single version number defined in the root `Cargo.toml` via `workspace.package.version`. This means every release bumps all crates together. The rationale: -1. The crates are tightly coupled — `dpp-domain` is a dependency of every - other crate. +1. The crates that depend on each other are tightly coupled, and `dpp-domain` + is the hub of that set — `dpp-aas`, `dpp-digital-link`, `dpp-registry`, + `dpp-vc` and `dpp-tests` all build on it. (`dpp-rules`, `dpp-crypto`, + `dpp-calc`, `dpp-vocab`, `dpp-plugin-traits` and `dpp-plugin-sdk` do not, so + lockstep bumps them for consistency rather than necessity.) 2. A single version makes it trivial for downstream consumers to ensure compatible combinations. 3. Once individual crates stabilise at different rates (post-1.0), lockstep From 1c182f547bfc48ebba32415a8fcd9f85a9b0a17d Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 04:05:09 +0200 Subject: [PATCH 2/7] docs(architecture): bring the code layout standard into the repository --- CONTRIBUTING.md | 13 ++ .../dpp-tests/tests/mod_rs_is_pure_index.rs | 6 +- docs/README.md | 1 + docs/architecture/CODE-LAYOUT.md | 167 ++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/CODE-LAYOUT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97c87a42..445c0f0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,19 @@ dpp-tests -> dpp-domain, dpp-crypto, dpp-digital-link, dpp-aas (dev only ## 4. Coding Conventions +### Where a file goes + +[`docs/architecture/CODE-LAYOUT.md`](docs/architecture/CODE-LAYOUT.md) is the +standard: one public type per file, `mod.rs` is a pure index, tests are siblings +rather than inline, every file opens with a `//!` doc, only `lib.rs` sits at a +crate's `src/` root. + +Most of those rules are enforced by tripwires in `crates/dpp-tests/tests/`, so +`just check` will tell you before a reviewer does. Each carries a baseline of +files that already violate it; those are being worked through. **Do not add to a +baseline to go green** — fix the file, or mark it with a +`// LAYOUT-DEVIATION: ` comment, which is greppable and has to state why. + ### Pure Domain Code Every module in this workspace must compile without I/O crates. If you are importing `axum`, `sqlx`, or `async-nats`, that code belongs downstream, not here. diff --git a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs index b93d6922..b08f7ada 100644 --- a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs +++ b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs @@ -5,9 +5,9 @@ //! `pub fn` definitions. This keeps every `mod.rs` skimmable and forces new //! types into their own named file as the crate grows. //! -//! One of several code-layout rules this repository holds itself to, and for a -//! long time the only one with a test behind it — which is why it is also the -//! only one that never drifted. +//! Rule 2 of `docs/architecture/CODE-LAYOUT.md`, and for a long time the only +//! rule there with a test behind it — which is why it is also the only one that +//! never drifted. use std::fs; use std::path::{Path, PathBuf}; diff --git a/docs/README.md b/docs/README.md index f3a69f67..5ed53936 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ This folder documents **the standard, not the product**: what a Digital Product | You're asking… | Read | |---|---| | "How is the library structured, and why hexagonal?" | [architecture/ARCHITECTURE.md](architecture/ARCHITECTURE.md) · [architecture/DESIGN-PATTERNS.md](architecture/DESIGN-PATTERNS.md) | +| "Where does this file go, and what enforces that?" | [architecture/CODE-LAYOUT.md](architecture/CODE-LAYOUT.md) | | "How do identity, signing, and verifiable credentials work?" | [architecture/IDENTITY.md](architecture/IDENTITY.md) | | "How do product group plugins run safely?" | [architecture/PLUGIN-HOST.md](architecture/PLUGIN-HOST.md) | | "How do passports link to their components and predecessors?" | [architecture/PRODUCT-LINEAGE.md](architecture/PRODUCT-LINEAGE.md) — design proposal, not yet implemented | diff --git a/docs/architecture/CODE-LAYOUT.md b/docs/architecture/CODE-LAYOUT.md new file mode 100644 index 00000000..2f8317ad --- /dev/null +++ b/docs/architecture/CODE-LAYOUT.md @@ -0,0 +1,167 @@ +# Code Layout + +Where code goes in this repository, and what enforces it. + +This is a standard, not a plan. Every rule below is either **enforced** by a +tripwire in `crates/dpp-tests/tests/` or explicitly marked **guidance**. There is +no third category, on purpose — see §4. + +--- + +## 1. The rules + +### Rule 1 — one public type per file, when the type has gravity + +A type has gravity when it has its own `impl` blocks, serde derives beyond a +plain derive line, or runs to roughly 40 lines including docs. Such a type gets +its own file, named for it in snake_case: `FacilitySnapshot` → `facility_snapshot.rs`. + +A type and its own error enum are *one* concept and belong together. Three or +more public types in a file is the point at which the file has stopped being +about one thing. + +### Rule 2 — `mod.rs` is a pure index + +Module docs, `pub use` re-exports, and submodule declarations. Zero `pub struct`, +`pub enum`, `pub trait`, `pub fn`. This keeps every `mod.rs` skimmable and forces +a new type into its own named file as a module grows. + +### Rule 3 — free functions group by verb-domain *(guidance)* + +`validation/batch.rs`, not one file per three-line helper. **Not enforced**, and +deliberately so — see §4. + +### Rule 4 — a `tests.rs` splits when it passes 400 lines + +Along the same seams its source split. A test file nobody can navigate is a test +file nobody reads before changing the thing it covers. + +### Rule 5 — an enum with `impl` gravity is a type under rule 1 + +State machines like `PassportStatus` count. A small closed enum that exists only +as one parent's field may ride along in the parent's file. + +### Rule 6 — only `lib.rs` at `src/` root + +Everything else lives in a directory module. `test_support.rs` is the single +permitted exception (rule 9). + +### Rule 7 — tests live in a sibling file, never inline + +`tests.rs` beside the module, or `golden_vectors.rs` where the module implements +a published methodology and the tests are its vectors. No `#[cfg(test)] mod tests {}` +blocks inside a source file. + +The reason is rule 4: tests inline in a source file have no size of their own, so +nothing can tell you when they have outgrown it. + +### Rule 8 — every file opens with a `//!` module doc + +Within the first three lines. A file that cannot say what it is for in one line +is usually a file that holds two things. + +### Rule 9 — shared test scaffolding is `test_support.rs` + +One name, one place: the crate root, feature-gated. Not `fixtures.rs`, not +`helpers.rs`. This is the one file rule 6 allows beside `lib.rs`, because +scaffolding that is hard to find gets rewritten instead of reused. + +### Rule 10 — integration tests are named for their kind + +In `crates/dpp-tests/tests/`, a **tripwire** — a test that asserts a structural +or documentary invariant rather than behaviour — is prefixed `layout_` when it +guards this document, and otherwise named for the invariant it guards +(`domain_concerns`, `ports_inventory`, `mod_rs_is_pure_index`, +`open_product_group_lane`, `provisional_schema_marker`, `schema_conformity`). +Behavioural tests are named for the behaviour (`battery_end_to_end`, +`access_gatekeeping`, `transfer_of_responsibility`). + +They fail for different reasons and are read by different people: a red tripwire +means the repo drifted from its own rules, a red behavioural test means the code +is wrong. + +> **A note on why this is a naming convention and not a directory.** Cargo +> auto-discovers test binaries only from `.rs` files at the top level of +> `tests/`. Moving these into `tests/tripwires/` would require an explicit +> `[[test]]` entry per file in `Cargo.toml` — and a tripwire that silently does +> not run because someone forgot an entry is a worse failure than a flat +> directory. Auto-discovery is the safer property; the prefix does the grouping. + +--- + +## 2. Deviations are legal, and counted + +Any file that knowingly breaks an enforced rule carries a marker on its own line: + +```rust +// LAYOUT-DEVIATION: +``` + +The tripwire honours the marker and the file stops failing. It does **not** stop +being visible: the marker is greppable, and reviewing them periodically is how +the rules get revised rather than quietly abandoned. + +A rule with no escape hatch is deleted the first time it is inconvenient. A rule +with a *counted* escape hatch survives. + +--- + +## 3. What enforces what + +| Rule | Tripwire | Fails when | +|---|---|---| +| 1, 5 | `layout_one_type_per_file` | a source file declares ≥3 public types | +| 2 | `mod_rs_is_pure_index` | a `mod.rs` declares a public item | +| 3 | — | *guidance only* | +| 4 | `layout_tests_files_are_navigable` | a `tests.rs` exceeds 400 lines | +| 6, 9 | `layout_only_lib_rs_at_root` | a crate has a root `.rs` other than `lib.rs` or `test_support.rs` | +| 7 | `layout_tests_are_siblings` | a source file contains an inline `#[cfg(test)] mod tests` | +| 8 | `layout_module_docs` | a `.rs` file has no `//!` in its first three lines | + +**Rule 1's tripwire is a proxy, not the rule.** It fires at three public types +because a type plus its error is idiomatic and should not need a marker. Two +unrelated types in a file still break rule 1; the tripwire simply cannot tell +"related" from "unrelated" and does not pretend to. + +### The baseline + +Each tripwire carries an explicit, enumerated list of the files that already +violate it, in the test file itself. Those are allowed. **Anything not on the +list fails.** So the rules bind for all new and moved code from the day they +landed, while the existing backlog is worked through separately, and the list +shrinking is the visible measure of that work. + +Entries are removed as files are fixed. **Never add to a baseline** to make a +build green — that is what the deviation marker is for, and unlike a baseline +entry a marker has to state a reason. + +--- + +## 4. Why every rule is either enforced or labelled guidance + +This standard existed before this document, in five numbered rules. Exactly one +of them had a test. That rule had **zero** violations. Every other rule had +many — twelve oversized test files, one file with twelve public types, twenty +eight files with no module doc, sixty one inline test modules. + +The rules were not wrong and nobody ignored them on purpose. They simply had +nothing watching them, and a rule with nothing watching it is a preference. +Preferences lose to deadlines. + +Rule 3 stays guidance because it cannot be tested without a definition of +"verb-domain" that nobody would agree on. That is a reason to label it honestly, +not a reason to promote it — an unenforceable rule sitting in a list of enforced +ones is what teaches a reader that the list is decorative. + +**A tripwire is not trusted until it has been seen to fail.** Introduce a +violation, watch it go red, revert. A gate nobody has watched fail is a gate +nobody knows is wired up. + +--- + +## 5. Scope + +These rules govern `crates/*/src/`, `plugins/*/src/`, and +`crates/dpp-tests/tests/`. They are invisible to consumers: every move is +internal, with `pub use` in `lib.rs` preserving public paths. A layout change +that alters the public API is not a layout change. From ce1517eb64097884375d91aa02fbf675c83a226c Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 04:12:55 +0200 Subject: [PATCH 3/7] test(layout): enforce the code layout rules with a baselined tripwire per rule --- crates/dpp-tests/tests/layout.rs | 548 +++++++++++++++++++++++++++++++ docs/architecture/CODE-LAYOUT.md | 20 +- 2 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 crates/dpp-tests/tests/layout.rs diff --git a/crates/dpp-tests/tests/layout.rs b/crates/dpp-tests/tests/layout.rs new file mode 100644 index 00000000..5902bb22 --- /dev/null +++ b/crates/dpp-tests/tests/layout.rs @@ -0,0 +1,548 @@ +//! Drift tripwires for `docs/architecture/CODE-LAYOUT.md`. +//! +//! One test per enforced rule. Rule 2 lives in its own file +//! (`mod_rs_is_pure_index.rs`) because it predates this one and works; the rest +//! are here, sharing one directory walk. +//! +//! # How these fail +//! +//! Each test carries a `BASELINE`: the files that already violated the rule when +//! it landed. Those are allowed. **Anything not on the list fails.** So the rules +//! bind for all new and moved code from day one while the backlog is worked +//! through separately, and a shrinking baseline is the visible measure of that. +//! +//! **Never add to a baseline to go green.** That is what +//! `// LAYOUT-DEVIATION: ` is for — it is greppable and it has to state a +//! reason, where a baseline entry states nothing. +//! +//! A stale baseline entry is also a failure: a file that has been fixed must be +//! removed from the list, or the list slowly stops describing anything. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Marker that exempts a file from an enforced layout rule. +const DEVIATION_MARKER: &str = "LAYOUT-DEVIATION:"; + +/// Rule 4's threshold, in lines. +const MAX_TESTS_FILE_LINES: usize = 400; + +/// Rule 1's proxy: a file with this many public types has stopped being about +/// one thing. Three rather than two because a type and its own error enum are +/// one concept and should not need a marker. +const MAX_PUBLIC_TYPES: usize = 3; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") +} + +/// Every `src` directory this standard governs: all workspace crates and all +/// Wasm plugins. +/// +/// Discovered rather than listed. A hardcoded roster is how a new crate ends up +/// silently unchecked, which is the same failure mode these tests exist to +/// prevent. +fn governed_src_dirs() -> Vec { + let root = workspace_root(); + let mut dirs = Vec::new(); + for group in ["crates", "plugins"] { + let Ok(entries) = fs::read_dir(root.join(group)) else { + continue; + }; + for entry in entries.flatten() { + let src = entry.path().join("src"); + if src.is_dir() { + dirs.push(src); + } + } + } + assert!( + dirs.len() > 10, + "expected to discover the crates and plugins, found {} — has the repo layout moved?", + dirs.len() + ); + dirs +} + +fn find_rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_rs_files(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(path); + } + } +} + +/// Repo-relative, forward-slashed, so a baseline entry reads the same on every +/// platform and in every diff. +fn rel(path: &Path) -> String { + let root = workspace_root(); + let root = root.canonicalize().unwrap_or(root); + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + path.strip_prefix(&root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/") +} + +fn has_deviation_marker(src: &str) -> bool { + src.contains(DEVIATION_MARKER) +} + +/// Compare found violations against the baseline and report both directions. +/// +/// Fails on a new violation *and* on a stale baseline entry, because a list that +/// is never pruned stops being a record of anything. +/// +/// `found` maps a repo-relative path to a human detail ("892 lines"). **Only the +/// path is matched against the baseline.** Putting the measurement in the key +/// would make every baselined file fail the moment anyone touched it for an +/// unrelated reason, and a tripwire that fires on innocent edits is one that +/// gets deleted. +fn assert_against_baseline(rule: &str, found: &BTreeMap, baseline: &[&str]) { + let baseline: BTreeSet = baseline.iter().map(|s| (*s).to_owned()).collect(); + let found_paths: BTreeSet = found.keys().cloned().collect(); + + let new: Vec<&String> = found_paths.difference(&baseline).collect(); + let fixed: Vec<&String> = baseline.difference(&found_paths).collect(); + + let mut message = String::new(); + if !new.is_empty() { + message.push_str(&format!( + "\n{rule}\n\nThese files break the rule and are not in the baseline:\n" + )); + for v in &new { + let detail = found.get(*v).map(String::as_str).unwrap_or(""); + if detail.is_empty() { + message.push_str(&format!(" {v}\n")); + } else { + message.push_str(&format!(" {v} ({detail})\n")); + } + } + message.push_str( + "\nFix the file, or mark it `// LAYOUT-DEVIATION: `. \ + Do not add it to the baseline.\n", + ); + } + if !fixed.is_empty() { + message.push_str(&format!( + "\n{rule}\n\nThese are in the baseline but no longer violate it — \ + remove them from the baseline:\n" + )); + for v in &fixed { + message.push_str(&format!(" {v}\n")); + } + } + assert!(message.is_empty(), "{message}"); +} + +/// Strip `///` and `//!` doc comments, and the fenced code blocks inside them, +/// so an illustrative `pub struct Foo` in an example is not counted as an item. +/// +/// Shared by the rule 1 and rule 7 scanners; both were fooled by doctests in an +/// earlier draft. +fn code_lines(src: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut in_doctest = false; + for line in src.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("///") || trimmed.starts_with("//!") { + let doc_text = trimmed.trim_start_matches("///").trim_start_matches("//!"); + if doc_text.trim_start().starts_with("```") { + in_doctest = !in_doctest; + } + continue; + } + if in_doctest || trimmed.starts_with("//") { + continue; + } + out.push(trimmed); + } + out +} + +// --------------------------------------------------------------------------- +// Rule 1 + 5 — one public type per file, when the type has gravity +// --------------------------------------------------------------------------- + +/// Files that already declared three or more public types when this landed. +const ONE_TYPE_PER_FILE_BASELINE: &[&str] = &[ + "crates/dpp-aas/src/model.rs", + "crates/dpp-calc/src/co2e/calculator.rs", + "crates/dpp-calc/src/co2e/cfb.rs", + "crates/dpp-calc/src/kernel/ruleset.rs", + "crates/dpp-calc/src/recycled_content/thresholds.rs", + "crates/dpp-calc/src/repairability/calculator.rs", + "crates/dpp-calc/src/repairability_index/thresholds.rs", + "crates/dpp-calc/src/ruleset_registry/status.rs", + "crates/dpp-crypto/src/jades/header.rs", + "crates/dpp-crypto/src/keystore/store.rs", + "crates/dpp-domain/src/catalog/passport_obligation.rs", + "crates/dpp-domain/src/domain/compliance.rs", + "crates/dpp-domain/src/domain/eol.rs", + "crates/dpp-domain/src/domain/gtin.rs", + "crates/dpp-domain/src/domain/identity.rs", + "crates/dpp-domain/src/domain/lint.rs", + "crates/dpp-domain/src/domain/product_group/data/battery.rs", + "crates/dpp-domain/src/domain/product_group/data/unsold_goods.rs", + "crates/dpp-domain/src/domain/product_group/enums.rs", + "crates/dpp-domain/src/domain/product_group/metrics.rs", + "crates/dpp-domain/src/domain/seal.rs", + "crates/dpp-domain/src/ports/archive.rs", + "crates/dpp-domain/src/ports/ghosts.rs", + "crates/dpp-domain/src/ports/registry_sync.rs", + "crates/dpp-domain/src/schemas/lens.rs", + "crates/dpp-domain/src/schemas/types.rs", + "crates/dpp-plugin-traits/src/meta.rs", + "crates/dpp-plugin-traits/src/result.rs", + "crates/dpp-plugin-traits/src/version.rs", + "crates/dpp-registry/src/error.rs", + "crates/dpp-registry/src/identifiers.rs", + "crates/dpp-registry/src/response.rs", + "crates/dpp-rules/src/batteries/recycled_content.rs", + "crates/dpp-rules/src/bundle/types.rs", + "crates/dpp-rules/src/chemicals/svhc.rs", + "crates/dpp-vc/src/credential/trust.rs", + "crates/dpp-vc/src/credential/types.rs", +]; + +#[test] +fn rule_1_one_public_type_per_file() { + let mut found: BTreeMap = BTreeMap::new(); + for dir in governed_src_dirs() { + let mut files = Vec::new(); + find_rs_files(&dir, &mut files); + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `mod.rs` is rule 2's problem; `lib.rs` legitimately re-exports; + // a tests file's fixtures are not the module's public surface. + if matches!(name, "mod.rs" | "lib.rs" | "tests.rs" | "golden_vectors.rs") { + continue; + } + let Ok(src) = fs::read_to_string(&path) else { + continue; + }; + if has_deviation_marker(&src) { + continue; + } + let count = code_lines(&src) + .iter() + .filter(|l| { + l.starts_with("pub struct ") + || l.starts_with("pub enum ") + || l.starts_with("pub trait ") + }) + .count(); + if count >= MAX_PUBLIC_TYPES { + found.insert(rel(&path), format!("{count} public types")); + } + } + } + assert_against_baseline( + "CODE-LAYOUT.md rule 1 — one public type per file, when the type has gravity", + &found, + ONE_TYPE_PER_FILE_BASELINE, + ); +} + +// --------------------------------------------------------------------------- +// Rule 4 — a tests file splits when it passes 400 lines +// --------------------------------------------------------------------------- + +const TESTS_FILE_SIZE_BASELINE: &[&str] = &[ + "crates/dpp-aas/src/tests.rs", + "crates/dpp-crypto/src/jades/tests.rs", + "crates/dpp-crypto/src/keystore/tests.rs", + "crates/dpp-digital-link/src/digital_link/tests.rs", + "crates/dpp-domain/src/access/tests.rs", + "crates/dpp-domain/src/catalog/tests.rs", + "crates/dpp-domain/src/domain/passport/tests.rs", + "crates/dpp-domain/src/domain/product_group/tests.rs", + "crates/dpp-domain/src/schemas/tests.rs", + "crates/dpp-registry/src/tests.rs", + "crates/dpp-vc/src/credential/tests.rs", + "crates/dpp-vc/src/tests.rs", +]; + +#[test] +fn rule_4_tests_files_are_navigable() { + let mut found: BTreeMap = BTreeMap::new(); + for dir in governed_src_dirs() { + let mut files = Vec::new(); + find_rs_files(&dir, &mut files); + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !matches!(name, "tests.rs" | "golden_vectors.rs") && !name.ends_with("_tests.rs") { + continue; + } + let Ok(src) = fs::read_to_string(&path) else { + continue; + }; + if has_deviation_marker(&src) { + continue; + } + let lines = src.lines().count(); + if lines > MAX_TESTS_FILE_LINES { + found.insert(rel(&path), format!("{lines} lines")); + } + } + } + assert_against_baseline( + "CODE-LAYOUT.md rule 4 — a tests file splits when it passes 400 lines", + &found, + TESTS_FILE_SIZE_BASELINE, + ); +} + +// --------------------------------------------------------------------------- +// Rule 6 + 9 — only lib.rs at src root, plus test_support.rs +// --------------------------------------------------------------------------- + +const ROOT_FILES_BASELINE: &[&str] = &[ + "crates/dpp-aas/src/builder.rs", + "crates/dpp-aas/src/mapper.rs", + "crates/dpp-aas/src/model.rs", + "crates/dpp-aas/src/property.rs", + "crates/dpp-aas/src/templates.rs", + "crates/dpp-aas/src/tests.rs", + "crates/dpp-plugin-sdk/src/abi.rs", + "crates/dpp-plugin-sdk/src/codec.rs", + "crates/dpp-plugin-sdk/src/entry.rs", + "crates/dpp-plugin-sdk/src/tests.rs", + "crates/dpp-plugin-sdk/src/validate.rs", + "crates/dpp-plugin-traits/src/error.rs", + "crates/dpp-plugin-traits/src/meta.rs", + "crates/dpp-plugin-traits/src/plugin.rs", + "crates/dpp-plugin-traits/src/result.rs", + "crates/dpp-plugin-traits/src/tests.rs", + "crates/dpp-plugin-traits/src/version.rs", + "crates/dpp-registry/src/endpoint.rs", + "crates/dpp-registry/src/error.rs", + "crates/dpp-registry/src/granularity.rs", + "crates/dpp-registry/src/identifiers.rs", + "crates/dpp-registry/src/payload.rs", + "crates/dpp-registry/src/response.rs", + "crates/dpp-registry/src/tests.rs", + "crates/dpp-registry/src/transfer.rs", + // Rule 9 names this `test_support.rs`; renaming it is a later phase. + "crates/dpp-tests/src/fixtures.rs", + "crates/dpp-vc/src/did_builder.rs", + "crates/dpp-vc/src/local_service.rs", + "crates/dpp-vc/src/passport_credential.rs", + "crates/dpp-vc/src/status_list.rs", + "crates/dpp-vc/src/tests.rs", + "plugins/product-group-textile/src/fibre_composition.rs", + "plugins/product-group-textile/src/unsold_goods.rs", +]; + +#[test] +fn rule_6_only_lib_rs_at_src_root() { + let mut found: BTreeMap = BTreeMap::new(); + for dir in governed_src_dirs() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() || path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Rule 9 makes `test_support.rs` the one permitted exception. + if matches!(name, "lib.rs" | "main.rs" | "test_support.rs") { + continue; + } + let src = fs::read_to_string(&path).unwrap_or_default(); + if has_deviation_marker(&src) { + continue; + } + found.insert(rel(&path), String::new()); + } + } + assert_against_baseline( + "CODE-LAYOUT.md rule 6 — only lib.rs at a crate's src root (rule 9 allows test_support.rs)", + &found, + ROOT_FILES_BASELINE, + ); +} + +// --------------------------------------------------------------------------- +// Rule 7 — tests live in a sibling file, never inline +// --------------------------------------------------------------------------- + +const INLINE_TESTS_BASELINE: &[&str] = &[ + "crates/dpp-calc/src/co2e/calculator.rs", + "crates/dpp-calc/src/co2e/cfb.rs", + "crates/dpp-calc/src/co2e/gwp_factors.rs", + "crates/dpp-calc/src/kernel/assessability.rs", + "crates/dpp-calc/src/kernel/clock.rs", + "crates/dpp-calc/src/kernel/receipt.rs", + "crates/dpp-calc/src/kernel/synthetic_factor.rs", + "crates/dpp-calc/src/repairability/calculator.rs", + "crates/dpp-crypto/src/jws/canonical.rs", + "crates/dpp-digital-link/src/digital_link/codec.rs", + "crates/dpp-digital-link/src/digital_link/element_string.rs", + "crates/dpp-digital-link/src/digital_link/qr.rs", + "crates/dpp-digital-link/src/digital_link/syntax_dictionary.rs", + "crates/dpp-digital-link/src/linktype/media_type.rs", + "crates/dpp-digital-link/src/linktype/vocabulary.rs", + "crates/dpp-domain/src/catalog/instrument_kind.rs", + "crates/dpp-domain/src/catalog/instrument_ref.rs", + "crates/dpp-domain/src/catalog/passport_obligation.rs", + "crates/dpp-domain/src/compliance/passthrough_registry.rs", + "crates/dpp-domain/src/compliance/passthrough_strategies.rs", + "crates/dpp-domain/src/domain/commodity_code.rs", + "crates/dpp-domain/src/domain/compliance.rs", + "crates/dpp-domain/src/domain/eol.rs", + "crates/dpp-domain/src/domain/error.rs", + "crates/dpp-domain/src/domain/graph.rs", + "crates/dpp-domain/src/domain/gtin.rs", + "crates/dpp-domain/src/domain/identity.rs", + "crates/dpp-domain/src/domain/lint.rs", + "crates/dpp-domain/src/domain/passport/reference.rs", + "crates/dpp-domain/src/domain/product_group/enums.rs", + "crates/dpp-domain/src/domain/product_group/product_group.rs", + "crates/dpp-domain/src/domain/product_identity.rs", + "crates/dpp-domain/src/domain/seal.rs", + "crates/dpp-domain/src/domain/status.rs", + "crates/dpp-domain/src/ports/archive.rs", + "crates/dpp-domain/src/ports/ghosts.rs", + "crates/dpp-domain/src/ports/passport_repo.rs", + "crates/dpp-domain/src/ports/registry_sync.rs", + "crates/dpp-domain/src/ports/seal/conformance.rs", + "crates/dpp-domain/src/schemas/lens.rs", + "crates/dpp-plugin-sdk/src/validate.rs", + "crates/dpp-registry/src/granularity.rs", + "crates/dpp-rules/src/batteries/chemistry.rs", + "crates/dpp-rules/src/batteries/degradation.rs", + "crates/dpp-rules/src/batteries/passport_content.rs", + "crates/dpp-rules/src/batteries/recycled_content.rs", + "crates/dpp-rules/src/chemicals/cas.rs", + "crates/dpp-rules/src/chemicals/surfactants.rs", + "crates/dpp-rules/src/chemicals/svhc.rs", + "crates/dpp-rules/src/common/country.rs", + "crates/dpp-rules/src/common/date.rs", + "crates/dpp-rules/src/common/numeric.rs", + "crates/dpp-rules/src/lint/battery.rs", + "crates/dpp-rules/src/lint/textile.rs", + "crates/dpp-rules/src/lint/unsold_goods.rs", + "crates/dpp-rules/src/metals/aluminium.rs", + "crates/dpp-rules/src/textiles/fibre.rs", + "crates/dpp-rules/src/unsold_goods/annex_vii.rs", + "crates/dpp-vc/src/local_service.rs", + "crates/dpp-vc/src/passport_credential.rs", + "crates/dpp-vc/src/status_list.rs", + // Every plugin keeps its tests inline; the plugin pass is a later phase. + "plugins/product-group-aluminium/src/lib.rs", + "plugins/product-group-battery/src/lib.rs", + "plugins/product-group-construction/src/lib.rs", + "plugins/product-group-detergent/src/lib.rs", + "plugins/product-group-electronics/src/lib.rs", + "plugins/product-group-furniture/src/lib.rs", + "plugins/product-group-steel/src/lib.rs", + "plugins/product-group-textile/src/lib.rs", + "plugins/product-group-toy/src/lib.rs", + "plugins/product-group-tyre/src/lib.rs", +]; + +#[test] +fn rule_7_tests_are_siblings_not_inline() { + let mut found: BTreeMap = BTreeMap::new(); + for dir in governed_src_dirs() { + let mut files = Vec::new(); + find_rs_files(&dir, &mut files); + for path in files { + let Ok(src) = fs::read_to_string(&path) else { + continue; + }; + if has_deviation_marker(&src) { + continue; + } + // An inline test module is `mod tests {`; the sibling-file form is + // `mod tests;`, which is what the rule asks for. + let inline = code_lines(&src).iter().any(|l| { + (l.starts_with("mod tests") || l.starts_with("pub mod tests")) && l.ends_with('{') + }); + if inline { + found.insert(rel(&path), String::new()); + } + } + } + assert_against_baseline( + "CODE-LAYOUT.md rule 7 — tests live in a sibling tests.rs, never inline", + &found, + INLINE_TESTS_BASELINE, + ); +} + +// --------------------------------------------------------------------------- +// Rule 8 — every file opens with a module doc +// --------------------------------------------------------------------------- + +const MODULE_DOCS_BASELINE: &[&str] = &[ + "crates/dpp-aas/src/mapper.rs", + "crates/dpp-aas/src/model.rs", + "crates/dpp-aas/src/product_groups/battery.rs", + "crates/dpp-aas/src/product_groups/electronics.rs", + "crates/dpp-aas/src/product_groups/textile.rs", + "crates/dpp-aas/src/property.rs", + "crates/dpp-aas/src/semantic_ids/mod.rs", + "crates/dpp-aas/src/templates.rs", + "crates/dpp-aas/src/tests.rs", + "crates/dpp-crypto/src/jws/tests.rs", + "crates/dpp-crypto/src/keystore/migration.rs", + "crates/dpp-crypto/src/keystore/rotation.rs", + "crates/dpp-crypto/src/keystore/tests.rs", + "crates/dpp-domain/src/access/policy.rs", + "crates/dpp-domain/src/access/tests.rs", + "crates/dpp-domain/src/domain/passport/view.rs", + "crates/dpp-domain/src/schemas/embedded.rs", + "crates/dpp-domain/src/schemas/tests.rs", + "crates/dpp-rules/src/canonical/hash.rs", + "crates/dpp-rules/src/canonical/tests.rs", + "crates/dpp-vc/src/credential/builder.rs", + "crates/dpp-vc/src/credential/revocation.rs", + "crates/dpp-vc/src/credential/tests.rs", + "crates/dpp-vc/src/credential/trust.rs", + "crates/dpp-vc/src/credential/types.rs", + "crates/dpp-vc/src/credential/verify.rs", + "crates/dpp-vc/src/tests.rs", + "crates/dpp-vocab/src/register/tests.rs", +]; + +#[test] +fn rule_8_every_file_has_module_docs() { + let mut found: BTreeMap = BTreeMap::new(); + for dir in governed_src_dirs() { + let mut files = Vec::new(); + find_rs_files(&dir, &mut files); + for path in files { + let Ok(src) = fs::read_to_string(&path) else { + continue; + }; + if has_deviation_marker(&src) { + continue; + } + let has_doc = src + .lines() + .take(3) + .any(|l| l.trim_start().starts_with("//!")); + if !has_doc { + found.insert(rel(&path), String::new()); + } + } + } + assert_against_baseline( + "CODE-LAYOUT.md rule 8 — every file opens with a `//!` module doc", + &found, + MODULE_DOCS_BASELINE, + ); +} diff --git a/docs/architecture/CODE-LAYOUT.md b/docs/architecture/CODE-LAYOUT.md index 2f8317ad..9d10bbec 100644 --- a/docs/architecture/CODE-LAYOUT.md +++ b/docs/architecture/CODE-LAYOUT.md @@ -108,15 +108,23 @@ with a *counted* escape hatch survives. ## 3. What enforces what -| Rule | Tripwire | Fails when | +All but rule 2 live in `crates/dpp-tests/tests/layout.rs`, one `#[test]` each, +sharing one directory walk. Rule 2 keeps its own file because it predates the +rest and works. + +| Rule | Test | Fails when | |---|---|---| -| 1, 5 | `layout_one_type_per_file` | a source file declares ≥3 public types | +| 1, 5 | `layout::rule_1_one_public_type_per_file` | a source file declares ≥3 public types | | 2 | `mod_rs_is_pure_index` | a `mod.rs` declares a public item | | 3 | — | *guidance only* | -| 4 | `layout_tests_files_are_navigable` | a `tests.rs` exceeds 400 lines | -| 6, 9 | `layout_only_lib_rs_at_root` | a crate has a root `.rs` other than `lib.rs` or `test_support.rs` | -| 7 | `layout_tests_are_siblings` | a source file contains an inline `#[cfg(test)] mod tests` | -| 8 | `layout_module_docs` | a `.rs` file has no `//!` in its first three lines | +| 4 | `layout::rule_4_tests_files_are_navigable` | a `tests.rs` exceeds 400 lines | +| 6, 9 | `layout::rule_6_only_lib_rs_at_src_root` | a crate has a root `.rs` other than `lib.rs`, `main.rs` or `test_support.rs` | +| 7 | `layout::rule_7_tests_are_siblings_not_inline` | a source file contains an inline `#[cfg(test)] mod tests {` | +| 8 | `layout::rule_8_every_file_has_module_docs` | a `.rs` file has no `//!` in its first three lines | + +The set of crates and plugins each one scans is **discovered from the directory +tree**, not listed. A hardcoded roster is how a new crate ends up silently +unchecked, which is the failure these tests exist to prevent. **Rule 1's tripwire is a proxy, not the rule.** It fires at three public types because a type plus its error is idiomatic and should not need a marker. Two From a309e026c9037765125f710471a4e0004097df7a Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 04:15:31 +0200 Subject: [PATCH 4/7] docs(layout): state the real reason the doc-comment stripper exists --- crates/dpp-tests/tests/layout.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/dpp-tests/tests/layout.rs b/crates/dpp-tests/tests/layout.rs index 5902bb22..776263d5 100644 --- a/crates/dpp-tests/tests/layout.rs +++ b/crates/dpp-tests/tests/layout.rs @@ -147,8 +147,9 @@ fn assert_against_baseline(rule: &str, found: &BTreeMap, baselin /// Strip `///` and `//!` doc comments, and the fenced code blocks inside them, /// so an illustrative `pub struct Foo` in an example is not counted as an item. /// -/// Shared by the rule 1 and rule 7 scanners; both were fooled by doctests in an -/// earlier draft. +/// Shared by the rule 1 and rule 7 scanners. Mirrors the same handling in +/// `mod_rs_is_pure_index.rs`, which needs it for the same reason: this crate's +/// doc comments contain a lot of illustrative Rust. fn code_lines(src: &str) -> Vec<&str> { let mut out = Vec::new(); let mut in_doctest = false; From fd1c58e2a41c7f3e0304dad2af62dafb493f20eb Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 17:21:08 +0200 Subject: [PATCH 5/7] fix(layout): strip a UTF-8 BOM before scanning --- crates/dpp-domain/src/access/policy.rs | 2 +- crates/dpp-domain/src/domain/passport/view.rs | 2 +- crates/dpp-tests/tests/layout.rs | 60 +++++++++++-------- crates/dpp-vc/src/credential/tests.rs | 2 +- 4 files changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/dpp-domain/src/access/policy.rs b/crates/dpp-domain/src/access/policy.rs index 2e8b8e98..c984ec84 100644 --- a/crates/dpp-domain/src/access/policy.rs +++ b/crates/dpp-domain/src/access/policy.rs @@ -1,4 +1,4 @@ -//! ProductGroup access policy types and disclosure-class lookup. +//! ProductGroup access policy types and disclosure-class lookup. use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/crates/dpp-domain/src/domain/passport/view.rs b/crates/dpp-domain/src/domain/passport/view.rs index 0ea2c392..a4613744 100644 --- a/crates/dpp-domain/src/domain/passport/view.rs +++ b/crates/dpp-domain/src/domain/passport/view.rs @@ -1,4 +1,4 @@ -//! [`PassportView`] — an audience-filtered, serialisable view of a passport. +//! [`PassportView`] — an audience-filtered, serialisable view of a passport. /// An audience-filtered, serialisable view of a /// [`Passport`](crate::domain::passport::Passport). diff --git a/crates/dpp-tests/tests/layout.rs b/crates/dpp-tests/tests/layout.rs index 776263d5..522d017e 100644 --- a/crates/dpp-tests/tests/layout.rs +++ b/crates/dpp-tests/tests/layout.rs @@ -97,6 +97,21 @@ fn has_deviation_marker(src: &str) -> bool { src.contains(DEVIATION_MARKER) } +/// Read a source file with any UTF-8 byte-order mark stripped. +/// +/// U+FEFF is not whitespace, so `trim_start` leaves it in place and a file an +/// editor saved with a BOM reads as `\u{feff}//! …`. Rule 8 then reports it as +/// having no module doc when line 1 plainly is one. Two files in this workspace +/// were baselined on exactly that misreading, and on Windows a BOM is one +/// careless "save as" away — so it is stripped once, here, for every rule. +fn read_source(path: &Path) -> Option { + let src = fs::read_to_string(path).ok()?; + Some(match src.strip_prefix('\u{feff}') { + Some(stripped) => stripped.to_owned(), + None => src, + }) +} + /// Compare found violations against the baseline and report both directions. /// /// Fails on a new violation *and* on a stale baseline entry, because a list that @@ -144,30 +159,25 @@ fn assert_against_baseline(rule: &str, found: &BTreeMap, baselin assert!(message.is_empty(), "{message}"); } -/// Strip `///` and `//!` doc comments, and the fenced code blocks inside them, -/// so an illustrative `pub struct Foo` in an example is not counted as an item. +/// Strip every comment line, so an illustrative `pub struct Foo` inside a doc +/// example is not counted as an item. /// /// Shared by the rule 1 and rule 7 scanners. Mirrors the same handling in /// `mod_rs_is_pure_index.rs`, which needs it for the same reason: this crate's /// doc comments contain a lot of illustrative Rust. +/// +/// No fence tracking, deliberately. Every line inside a ```` ``` ```` block in a +/// doc comment is itself a `///` or `//!` line, so dropping comment lines +/// already drops the examples. An earlier version carried an `in_doctest` flag +/// that could never be observed on a code line — but an odd number of fences +/// anywhere in a file latched it `true` and silently swallowed every remaining +/// line, which would have taken rules 1 and 7 off duty for that file with +/// nothing going red. fn code_lines(src: &str) -> Vec<&str> { - let mut out = Vec::new(); - let mut in_doctest = false; - for line in src.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("///") || trimmed.starts_with("//!") { - let doc_text = trimmed.trim_start_matches("///").trim_start_matches("//!"); - if doc_text.trim_start().starts_with("```") { - in_doctest = !in_doctest; - } - continue; - } - if in_doctest || trimmed.starts_with("//") { - continue; - } - out.push(trimmed); - } - out + src.lines() + .map(str::trim) + .filter(|line| !line.starts_with("//")) + .collect() } // --------------------------------------------------------------------------- @@ -228,7 +238,7 @@ fn rule_1_one_public_type_per_file() { if matches!(name, "mod.rs" | "lib.rs" | "tests.rs" | "golden_vectors.rs") { continue; } - let Ok(src) = fs::read_to_string(&path) else { + let Some(src) = read_source(&path) else { continue; }; if has_deviation_marker(&src) { @@ -284,7 +294,7 @@ fn rule_4_tests_files_are_navigable() { if !matches!(name, "tests.rs" | "golden_vectors.rs") && !name.ends_with("_tests.rs") { continue; } - let Ok(src) = fs::read_to_string(&path) else { + let Some(src) = read_source(&path) else { continue; }; if has_deviation_marker(&src) { @@ -361,7 +371,7 @@ fn rule_6_only_lib_rs_at_src_root() { if matches!(name, "lib.rs" | "main.rs" | "test_support.rs") { continue; } - let src = fs::read_to_string(&path).unwrap_or_default(); + let src = read_source(&path).unwrap_or_default(); if has_deviation_marker(&src) { continue; } @@ -461,7 +471,7 @@ fn rule_7_tests_are_siblings_not_inline() { let mut files = Vec::new(); find_rs_files(&dir, &mut files); for path in files { - let Ok(src) = fs::read_to_string(&path) else { + let Some(src) = read_source(&path) else { continue; }; if has_deviation_marker(&src) { @@ -502,9 +512,7 @@ const MODULE_DOCS_BASELINE: &[&str] = &[ "crates/dpp-crypto/src/keystore/migration.rs", "crates/dpp-crypto/src/keystore/rotation.rs", "crates/dpp-crypto/src/keystore/tests.rs", - "crates/dpp-domain/src/access/policy.rs", "crates/dpp-domain/src/access/tests.rs", - "crates/dpp-domain/src/domain/passport/view.rs", "crates/dpp-domain/src/schemas/embedded.rs", "crates/dpp-domain/src/schemas/tests.rs", "crates/dpp-rules/src/canonical/hash.rs", @@ -526,7 +534,7 @@ fn rule_8_every_file_has_module_docs() { let mut files = Vec::new(); find_rs_files(&dir, &mut files); for path in files { - let Ok(src) = fs::read_to_string(&path) else { + let Some(src) = read_source(&path) else { continue; }; if has_deviation_marker(&src) { diff --git a/crates/dpp-vc/src/credential/tests.rs b/crates/dpp-vc/src/credential/tests.rs index c148974e..299226cb 100644 --- a/crates/dpp-vc/src/credential/tests.rs +++ b/crates/dpp-vc/src/credential/tests.rs @@ -1,4 +1,4 @@ -use chrono::{Duration, Utc}; +use chrono::{Duration, Utc}; use crate::status_list::StatusList; From 8ba524c5b5097b80548cc1238192f25092b8f456 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 17:21:09 +0200 Subject: [PATCH 6/7] fix(layout): discover rule 2's crates, don't list them --- .../dpp-tests/tests/mod_rs_is_pure_index.rs | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs index b08f7ada..d02abbd7 100644 --- a/crates/dpp-tests/tests/mod_rs_is_pure_index.rs +++ b/crates/dpp-tests/tests/mod_rs_is_pure_index.rs @@ -1,4 +1,4 @@ -//! Drift tripwire: no `mod.rs` in a published crate may declare a public item. +//! Drift tripwire: no `mod.rs` in the workspace may declare a public item. //! //! A `mod.rs` is a pure index — module docs, `pub use` re-exports, and //! submodule declarations only. Zero `pub struct` / `pub enum` / `pub trait` / @@ -12,23 +12,42 @@ use std::fs; use std::path::{Path, PathBuf}; -const PUBLISHED_CRATES: &[&str] = &[ - "dpp-domain", - "dpp-crypto", - "dpp-digital-link", - "dpp-plugin-traits", - "dpp-plugin-sdk", - "dpp-registry", - "dpp-rules", - "dpp-calc", -]; - fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") } +/// Every `src` directory this rule governs, per `CODE-LAYOUT.md` §5: all +/// workspace crates and all Wasm plugins. +/// +/// Discovered rather than listed, matching `layout.rs`. This test previously +/// carried a hand-written roster of eight crates, and `dpp-aas`, `dpp-vc` and +/// `dpp-vocab` — all three published — were never on it, so rule 2 had simply +/// never run against them. That is the exact failure the standard names: a +/// hardcoded roster is how a new crate ends up silently unchecked. +fn governed_src_dirs() -> Vec { + let root = workspace_root(); + let mut dirs = Vec::new(); + for group in ["crates", "plugins"] { + let Ok(entries) = fs::read_dir(root.join(group)) else { + continue; + }; + for entry in entries.flatten() { + let src = entry.path().join("src"); + if src.is_dir() { + dirs.push(src); + } + } + } + assert!( + dirs.len() > 10, + "expected to discover the crates and plugins, found {} — has the repo layout moved?", + dirs.len() + ); + dirs +} + /// Recursively collect every `mod.rs` under `dir`. fn find_mod_rs_files(dir: &Path, out: &mut Vec) { let Ok(entries) = fs::read_dir(dir) else { @@ -65,31 +84,23 @@ fn declares_public_item(line: &str) -> bool { #[test] fn mod_rs_files_are_pure_indexes() { - let root = workspace_root(); let mut violations = Vec::new(); - for krate in PUBLISHED_CRATES { - let src_dir = root.join("crates").join(krate).join("src"); + for src_dir in governed_src_dirs() { let mut mod_files = Vec::new(); find_mod_rs_files(&src_dir, &mut mod_files); for path in mod_files { let src = fs::read_to_string(&path) .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); - let mut in_doctest = false; for (i, line) in src.lines().enumerate() { let trimmed = line.trim(); - // Skip fenced code blocks inside `///`/`//!` doc comments — - // illustrative snippets (e.g. "pub trait Foo" in an example) - // aren't real items in this file. - if trimmed.starts_with("///") || trimmed.starts_with("//!") { - let doc_text = trimmed.trim_start_matches("///").trim_start_matches("//!"); - if doc_text.trim_start().starts_with("```") { - in_doctest = !in_doctest; - } - continue; - } - if in_doctest || trimmed.starts_with("//") { + // Every comment line goes, which drops the illustrative snippets + // inside fenced doc examples with it — a `pub trait Foo` in an + // example is not an item in this file. No fence tracking: see + // `code_lines` in `layout.rs` for why a latching flag is worse + // than none. + if trimmed.starts_with("//") { continue; } if declares_public_item(trimmed) { From bda9be328fd4f1e88a5ff6e45b232377b830e419 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 17:21:09 +0200 Subject: [PATCH 7/7] docs(layout): correct counts and classify rule 10 --- docs/architecture/CODE-LAYOUT.md | 37 ++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/architecture/CODE-LAYOUT.md b/docs/architecture/CODE-LAYOUT.md index 9d10bbec..b1775463 100644 --- a/docs/architecture/CODE-LAYOUT.md +++ b/docs/architecture/CODE-LAYOUT.md @@ -66,15 +66,15 @@ One name, one place: the crate root, feature-gated. Not `fixtures.rs`, not `helpers.rs`. This is the one file rule 6 allows beside `lib.rs`, because scaffolding that is hard to find gets rewritten instead of reused. -### Rule 10 — integration tests are named for their kind +### Rule 10 — integration tests are named for their kind *(guidance)* In `crates/dpp-tests/tests/`, a **tripwire** — a test that asserts a structural -or documentary invariant rather than behaviour — is prefixed `layout_` when it -guards this document, and otherwise named for the invariant it guards -(`domain_concerns`, `ports_inventory`, `mod_rs_is_pure_index`, -`open_product_group_lane`, `provisional_schema_marker`, `schema_conformity`). -Behavioural tests are named for the behaviour (`battery_end_to_end`, -`access_gatekeeping`, `transfer_of_responsibility`). +or documentary invariant rather than behaviour — is named for the invariant it +guards: `layout` for this document, and otherwise `domain_concerns`, +`ports_inventory`, `mod_rs_is_pure_index`, `open_product_group_lane`, +`provisional_schema_marker`, `schema_conformity`. Behavioural tests are named +for the behaviour (`battery_end_to_end`, `access_gatekeeping`, +`transfer_of_responsibility`). They fail for different reasons and are read by different people: a red tripwire means the repo drifted from its own rules, a red behavioural test means the code @@ -85,7 +85,7 @@ is wrong. > `tests/`. Moving these into `tests/tripwires/` would require an explicit > `[[test]]` entry per file in `Cargo.toml` — and a tripwire that silently does > not run because someone forgot an entry is a worse failure than a flat -> directory. Auto-discovery is the safer property; the prefix does the grouping. +> directory. Auto-discovery is the safer property; the name does the grouping. --- @@ -108,9 +108,9 @@ with a *counted* escape hatch survives. ## 3. What enforces what -All but rule 2 live in `crates/dpp-tests/tests/layout.rs`, one `#[test]` each, -sharing one directory walk. Rule 2 keeps its own file because it predates the -rest and works. +Every enforced rule but rule 2 lives in `crates/dpp-tests/tests/layout.rs`, one +`#[test]` each, sharing one directory walk. Rule 2 keeps its own file because it +predates the rest and works. | Rule | Test | Fails when | |---|---|---| @@ -121,6 +121,7 @@ rest and works. | 6, 9 | `layout::rule_6_only_lib_rs_at_src_root` | a crate has a root `.rs` other than `lib.rs`, `main.rs` or `test_support.rs` | | 7 | `layout::rule_7_tests_are_siblings_not_inline` | a source file contains an inline `#[cfg(test)] mod tests {` | | 8 | `layout::rule_8_every_file_has_module_docs` | a `.rs` file has no `//!` in its first three lines | +| 10 | — | *guidance only* | The set of crates and plugins each one scans is **discovered from the directory tree**, not listed. A hardcoded roster is how a new crate ends up silently @@ -149,17 +150,21 @@ entry a marker has to state a reason. This standard existed before this document, in five numbered rules. Exactly one of them had a test. That rule had **zero** violations. Every other rule had -many — twelve oversized test files, one file with twelve public types, twenty -eight files with no module doc, sixty one inline test modules. +many — twelve oversized test files, two files with twelve public types, twenty +six files with no module doc, seventy one inline test modules. The rules were not wrong and nobody ignored them on purpose. They simply had nothing watching them, and a rule with nothing watching it is a preference. Preferences lose to deadlines. Rule 3 stays guidance because it cannot be tested without a definition of -"verb-domain" that nobody would agree on. That is a reason to label it honestly, -not a reason to promote it — an unenforceable rule sitting in a list of enforced -ones is what teaches a reader that the list is decorative. +"verb-domain" that nobody would agree on. Rule 10 stays guidance for the same +kind of reason: a test could assert that every file in `tests/` is on a known +list, but the list would have to be hand-maintained, so it would catch a +*rename* and miss the thing that actually matters — a tripwire named as though +it were behavioural. That is a reason to label both honestly, not a reason to +promote them — an unenforceable rule sitting in a list of enforced ones is what +teaches a reader that the list is decorative. **A tripwire is not trusted until it has been seen to fail.** Introduce a violation, watch it go red, revert. A gate nobody has watched fail is a gate