From 004e1b7c2327918c654b21da33e44c4711354169 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 13 Jul 2026 21:37:07 +0100 Subject: [PATCH 01/29] Plan single-file GNU Make parsing Define the approval-gated implementation milestones for ADR-0001, including the schema, parser boundary, failure contract, test strategy, and review gates. Import the OrthoConfig 0.8.0 guide used by the CLI plan and synchronize the design, documentation index, repository layout, and generated Oxford spelling mappings. --- docs/contents.md | 6 + docs/design.md | 99 +- .../adr-0001-single-file-gnu-make-parse.md | 763 ++++++++++ docs/ortho-config-users-guide.md | 1323 +++++++++++++++++ docs/repository-layout.md | 10 + typos.toml | 36 + 6 files changed, 2224 insertions(+), 13 deletions(-) create mode 100644 docs/execplans/adr-0001-single-file-gnu-make-parse.md create mode 100644 docs/ortho-config-users-guide.md diff --git a/docs/contents.md b/docs/contents.md index 1cc5193..7f2157f 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -25,6 +25,10 @@ set. architecture, security boundaries, and verification strategy. - [ADR-0001: Parse one GNU Makefile into versioned JSON facts](adrs/0001-single-file-gnu-make-parse.md) records the proposed boundary for the first implementation slice. +- [Execution plans](execplans/) describe approved, milestone-oriented delivery + work: + - [Implement ADR-0001](execplans/adr-0001-single-file-gnu-make-parse.md) + plans the single-file GNU Make parser and its verification. ## Rust reference material @@ -38,6 +42,8 @@ set. - [`rstest-bdd` user's guide](rstest-bdd-users-guide.md) explains how to write and run Behaviour-Driven Development scenarios, step definitions, and fixtures with `rstest-bdd`. +- [OrthoConfig user's guide](ortho-config-users-guide.md) documents the + configuration and command-line derivation library used by the CLI adapter. ## Engineering practice diff --git a/docs/design.md b/docs/design.md index f248a10..c9c4472 100644 --- a/docs/design.md +++ b/docs/design.md @@ -172,7 +172,8 @@ facts. ### 6.2. Source locations -All facts that originate from source carry a location: +All facts and conditional contexts that originate from source carry the same +complete location shape: ```json { @@ -189,6 +190,28 @@ Byte ranges use a zero-based, end-exclusive convention. Lines and columns are one-based for diagnostic consumers. Columns count UTF-8 bytes, matching Rowan's offsets and avoiding an unadvertised character-width conversion. +Locations own these source ranges: + +- A rule covers the complete rule node, from the first target byte through the + final recipe byte and line ending when one exists. +- A recipe covers its complete physical source, including the leading recipe + tab, any `@`, `-`, or `+` modifiers, continuations, and final line ending. +- A variable covers the complete definition or directive, including modifiers, + assignment operator, value, and final line ending. +- An include covers the complete include directive and final line ending. +- A conditional context covers only its opening `ifdef`, `ifndef`, `ifeq`, or + `ifneq` directive, or its `else` directive, including that directive's final + line ending. It does not cover the nested arm or closing `endif`. +- A diagnostic uses the upstream positioned range. When only a line is known, + it covers that complete line excluding its line ending. An end-of-file + diagnostic is a zero-length range at `byte_length`. + +A range must satisfy `start_byte <= end_byte <= source.byte_length`. Empty and +zero-length ranges are valid insertion points. End-of-file positions identify +the point immediately after the final byte. In CRLF input, the carriage return +and line feed remain bytes on the preceding line; the next line begins after +the line feed. + ### 6.3. Parse diagnostics A positioned parse diagnostic contains: @@ -223,8 +246,12 @@ outer-to- inner context: "expression": "CI", "branch": "if", "location": { + "start_byte": 120, + "end_byte": 129, "start_line": 10, - "start_column": 1 + "start_column": 1, + "end_line": 11, + "end_column": 1 } } ] @@ -300,18 +327,31 @@ function marker. `makeutil` reports includes but never opens them. ## 7. Internal architecture -| Component | Responsibility | -| -------------- | ------------------------------------------------------------------------------------------- | -| CLI front end | Parse the command and validate that exactly one source was supplied. | -| Source reader | Read a path or stdin, validate UTF-8, retain bytes, and calculate SHA-256. | -| Parser adapter | Invoke `makefile-lossless`, expose the recovered tree, and translate diagnostics. | -| Fact collector | Walk root and conditional items, flatten facts, and attach condition ancestry and ordinals. | -| Location index | Convert byte offsets into one-based line and byte-column positions. | -| JSON reporter | Serialize schema version 1 deterministically to standard output. | +| Component | Responsibility | +| -------------- | ---------------------------------------------------------------------------------------------- | +| CLI front end | Parse the command and validate that exactly one source was supplied. | +| Source reader | Read one path or stdin into bytes without interpreting or normalizing it. | +| Parser adapter | Invoke `makefile-lossless` and return ordered owned observations and diagnostics. | +| Fact collector | Flatten observations, attach conditions and locations, assign ordinals, and calculate SHA-256. | +| Location index | Convert byte offsets into one-based line and byte-column positions. | +| JSON reporter | Serialize schema version 1 deterministically to standard output. | The package may expose a Rust library internally for unit tests, but only the CLI and JSON schema form a supported integration contract in the first release. +The domain owns report types, source spans and locations, conditional ancestry, +ordinal assignment, diagnostic ordering, source hashing, and complete versus +recovered classification. A domain-owned parser port accepts UTF-8 text and +returns ordered makeutil-owned syntax observations, source spans, and +diagnostics. The `makefile-lossless` adapter implements the port and proves its +own complete-tree round trip; it never returns Rowan nodes, upstream errors, or +rendered CST bytes through the port. + +The composition root parses the CLI, invokes the source reader, calls the +application service, and hands the completed report to the JSON reporter. Edge +adapters do not call each other. Source and reporter traits are introduced only +when deterministic failure testing requires them; they are not domain ports. + ## 8. Parse and traversal flow 1. Read the source bytes. @@ -332,11 +372,17 @@ rules into an effective rule. ## 9. Determinism -- Arrays preserve source order. +- Rules, variables, and includes have separate arrays, but their top-level + `ordinal` values share one zero-based, gap-free sequence ordered by + `location.start_byte`. Equal offsets retain upstream observation order. + Recipe ordinals are zero-based and local to their containing rule. +- Diagnostics retain upstream emission order; the adapter does not sort, + deduplicate, or rewrite messages or codes. - Object fields use a fixed serialization order derived from Rust structures. - Compact JSON ends with one newline. -- The source path is the normalized caller-supplied logical path, not a - canonicalized absolute path. +- The source path preserves the caller-supplied UTF-8 spelling byte-for-byte. + It is neither canonicalized nor lexically cleaned. In stdin mode it is + exactly the value of `--stdin-filename`. - The digest covers the exact input bytes. - No timestamps, hostnames, temporary paths, or environment values appear in output. @@ -353,6 +399,33 @@ rules into an effective rule. - Resource limits may be added later if corpus evidence shows pathological inputs; the first slice still includes large-file and deep-conditional tests. +The security suite uses source-selected filesystem sentinels for `$(shell ...)`, +`$(file ...)`, `!=`, and recipes. It separately traces file-open system calls +and proves that literal and dynamic include paths are not opened; absence of a +side effect alone is not evidence that an include was not read. + +### 10.1. Failure and observability contract + +The reporter serializes the complete JSON document into memory before writing +stdout. Serialization failure therefore emits no JSON. An output write failure, +including a broken pipe, exits 2. The operating system may already have +accepted a prefix of the buffered document, so partial stdout is permitted only +for this failure class. + +Fatal stderr diagnostics have one stable first line: + +```plaintext +makeutil[]: : +``` + +Operation identifiers distinguish `cli`, `source-open`, `source-read`, +`source-utf8`, `parse-internal`, `json-serialize`, and `stdout-write`. Normal +success and recovered parsing emit no stderr. Backtraces and cause chains are +not printed by default. The binary may install one tracing subscriber, but it +must never write tracing events to stdout; the library installs no subscriber. +Source contents and unbounded raw paths are not tracing fields. This one-shot +CLI emits no metrics in the first slice. + ## 11. Verification strategy ### 11.1. Fixture classes diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md new file mode 100644 index 0000000..1e4e7ca --- /dev/null +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -0,0 +1,763 @@ +# Implement single-file GNU Make parsing + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, +and `Outcomes & retrospective` must be kept up to date as work proceeds. + +Status: DRAFT — implementation requires explicit user approval. + +## Approval + +- Approver: Pending. +- Approval date: Pending. +- Exact `makefile-lossless = "=0.3.40"` exception: Pending explicit approval. + +When approval is granted, record the approver, date, exact-pin decision, and +change `Status` to `APPROVED` before beginning Milestone 1. Silence or approval +of a different document does not satisfy this gate. + +## Purpose / big picture + +Implement [ADR-0001](../adrs/0001-single-file-gnu-make-parse.md) so an operator +can run `makeutil parse PATH`, or provide one Makefile on standard input, and +receive one deterministic, versioned JSON document containing source-faithful +GNU Make facts. Malformed but recoverable source must still produce facts and +diagnostics with exit code 1; invocation, input, encoding, reporting, and +internal failures must use exit code 2. Source text must remain inert: parsing +must never invoke GNU Make, a shell, a recipe, a Make function, or an included +file. + +Success is observable by running the compiled binary against complete, +recoverable, non-UTF-8, and hostile fixtures and observing the documented JSON, +streams, exit codes, byte-for-byte repeatability, and absence of sentinel side +effects. + +## Constraints + +- Do not begin implementation until the user explicitly approves this plan. +- Implement only the single-file GNU Make parse slice in ADR-0001. Do not add + discovery, include traversal, evaluation, policy decisions, mutation, + language bindings, batch processing, daemon behaviour, or another Make + dialect. +- Treat the JSON schema version, not Rust types or the upstream concrete syntax + tree (CST), as the stable integration contract. No Rowan or + `makefile-lossless` type may cross into the domain model or public JSON. +- Parse source without invoking GNU Make, a shell, or any source-selected + command. Open only the caller-supplied input path; report includes without + opening them. +- Preserve exact input bytes through a complete upstream lossless round trip. + Reject non-UTF-8 input before parsing. +- Keep byte ranges zero-based and end-exclusive. Keep displayed lines and byte + columns one-based, including for multibyte UTF-8 and CRLF input. +- Preserve source order, use fixed serialized field order, append exactly one + newline to compact JSON, and exclude host-dependent or time-dependent data. +- Use `cap_std`, `cap_std::fs_utf8`, and `camino` at filesystem boundaries in + place of `std::fs` and `std::path`. +- Compile and test with the pinned nightly and Polonius. Preserve direct, + borrow-centric forms described in [Polonius migration](../polonius.md). +- Every Rust module must begin with a `//!` comment, every public API must have + Rustdoc with a useful example, and no Rust source file may exceed 400 lines. +- Follow Red-Green-Refactor for every behaviour milestone. The red command must + fail for the expected missing behaviour before production code is added. +- Use `rstest` for fixtures and parameterized tests, `rstest-bdd` version + `0.6.0-beta3` for behavioural scenarios, `googletest` matchers for semantic + assertions, and `pretty_assertions` for equality failures where a structural + diff is clearer. Use `insta` for stable multi-variant JSON output. +- Tests must not mutate the environment of the shared test process. Set + environment variables only on child processes if a case requires them. +- Run `make check-fmt`, `make typecheck`, `make lint`, and `make test` after + every major milestone, then have a scrutineer run + `coderabbit review --agent`. Resolve all deterministic gate failures before + requesting CodeRabbit. Resolve applicable CodeRabbit concerns as a separate + review action before proceeding. +- Commit each accepted milestone atomically after its gates and review pass. +- Update user-facing behaviour in [the user's guide](../users-guide.md), + internal interfaces and ownership in [the design](../design.md), repository + paths in [the repository layout](../repository-layout.md), and contributor + practices in [the developer's guide](../developers-guide.md). + +The ADR and design require the exceptional exact requirement +`makefile-lossless = "=0.3.40"`, while repository policy normally mandates +caret requirements. Approval of this plan approves that narrow, documented +exception for this dependency only. If approval does not include the exception, +stop and resolve the conflict before editing `Cargo.toml`. + +## Tolerances (exception triggers) + +- Scope: stop if the implementation needs more than 35 new or modified tracked + files, excluding fixture and snapshot files, or more than 2,500 net lines of + Rust. Present a smaller decomposition before proceeding. +- Contract: stop if a required JSON field, location convention, stream rule, or + exit code must differ from ADR-0001 or `docs/design.md`. +- Dependencies: stop before adding a runtime dependency not named in + `Interfaces and dependencies`, or changing the exact upstream parser version. +- Upstream API: stop if `makefile-lossless` 0.3.40 cannot expose a required fact + or source range without patching/forking it, or cannot losslessly retain a + recovered tree. +- Input semantics: stop if implementation cannot preserve the exact + caller-supplied UTF-8 path spelling, or the exact `--stdin-filename` value, + without canonicalization or lexical cleaning. +- Testability: stop if a fatal or recovered path can be tested only by panic, + shared-process environment mutation, or lint suppression. +- Quality: after three unsuccessful attempts to fix the same deterministic + gate or CodeRabbit blocker, record evidence and escalate rather than masking + it. +- Performance: stop if a 10 MiB fixture takes over two seconds or a 256-level + conditional fixture uses more than 256 MiB resident memory on the development + machine in three consecutive release-mode measurements. These are guardrails, + not a public performance guarantee. +- Ambiguity: stop when two reasonable interpretations would produce different + schema-v1 JSON or externally observable CLI behaviour. + +## Risks + +- Risk: upstream 0.3.40 accessors may not expose every location or conditional + detail in the desired shape. Severity: high. Likelihood: medium. Mitigation: + begin with a disposable, additive contract spike using the exact crate + version and representative fixtures; promote only proven API mappings. +- Risk: an upstream recovered parse may expose facts differently from a + complete `FromStr` parse. Severity: high. Likelihood: medium. Mitigation: use + the upstream `Parse` result and its ordinary and positioned errors directly; + snapshot both complete and recovered reports. +- Risk: upstream node ranges may not align with the construct ownership now + fixed in `docs/design.md` section 6.2. Severity: high. Likelihood: medium. + Mitigation: adapter contract tests compare every upstream range with the + exact expected source slice. Derive a makeutil-owned range only from child + ranges when the result is identical to the approved contract; otherwise stop. +- Risk: the ADR's exact pin conflicts with the repository-wide caret policy. + Severity: medium. Likelihood: certain. Mitigation: treat explicit plan + approval as approval of one scoped exception, document it in the design and + developer guide, and do not generalize it. +- Risk: a schema represented only by Rust structs and snapshots is difficult + for Concordat to validate independently. Severity: medium. Likelihood: + medium. Mitigation: add a checked JSON Schema artefact for schema version 1 + and test representative complete and recovered documents against it. +- Risk: tests might claim “no execution” while only testing ordinary recipes. + Severity: high. Likelihood: medium. Mitigation: end-to-end hostile fixtures + contain `$(shell ...)`, `!=`, recipe commands, dynamic includes, and literal + includes that would create a sentinel if evaluated or opened. Assert the + sentinel remains absent. +- Risk: the Concordat integration criterion is outside this repository. + Severity: medium. Likelihood: high. Mitigation: provide a consumer-shaped + deserialization fixture and record the external Concordat trial as evidence + required before ADR status moves from Proposed to Accepted; do not fabricate + cross-repository proof. +- Risk: strict lints and code-size limits may encourage premature abstraction. + Severity: medium. Likelihood: medium. Mitigation: keep modules cohesive, + sweep for equivalent helpers before every extraction, and add a trait only at + the volatile parser boundary or when a deterministic failure path requires + injection. + +## Progress + +- [x] (2026-07-13) Created the Leta workspace and mapped the scaffold, ADR, + design, documentation, test guidance, and build gates with a Wyvern team. +- [x] (2026-07-13) Confirmed upstream `makefile-lossless` 0.3.40 exposes a + lossless tree, recovered results, and ordinary and positioned diagnostics. +- [x] (2026-07-13) Imported the OrthoConfig user's guide from + `../../ortho-config/docs/users-guide.md` and indexed it. +- [x] (2026-07-13) Completed the Logisphere community review and revised the + design to freeze logical-path spelling, construct ranges, ordinal ownership, + diagnostics, failure output, and observability before approval. +- [x] (2026-07-13) Passed all planning milestone deterministic gates and + resolved every actionable concern from three CodeRabbit review rounds. +- [ ] Obtain a clean CodeRabbit follow-up after the service rate limit resets; + the post-fix attempt stopped before analysis and emitted no new findings. +- [ ] Obtain explicit approval of this ExecPlan, including the exact parser pin + exception and the schema/path decisions. +- [ ] Milestone 1: prove upstream contracts and freeze makeutil-owned domain and + schema-v1 boundaries with red tests. +- [ ] Milestone 2: implement parser traversal, locations, and recovered output + against the fixture corpus. +- [ ] Milestone 3: implement OrthoConfig CLI, source, JSON, and process adapters + with behavioural and end-to-end validation. +- [ ] Milestone 4: synchronize documentation, run full acceptance, and gather + external consumer evidence. + +## Surprises & discoveries + +- Observation: the repository contains only greeting and test stubs; there is + no existing parser, CLI, model, I/O, or test abstraction to extend. Evidence: + Leta finds only `greet`, `main`, and + `replace_this_stub_when_real_tests_exist`; `Cargo.toml` has no dependencies. + Impact: this is the first real application boundary, but it should remain a + small set of cohesive modules rather than receive a framework-sized layout. +- Observation: upstream 0.3.40 exports `Parse`, `PositionedParseError`, + `MakefileVariant`, and lossless CST types and retains parser errors alongside + a tree. Evidence: the 0.3.40 docs.rs and tagged source expose these APIs and + upstream tests assert recovered-tree and hostile-input round trips. Impact: + an adapter can preserve partial evidence without exposing upstream types, but + its contract must be proven before model implementation. +- Observation: the requested OrthoConfig guide originally did not exist in this + checkout. Evidence: the source guide lived at + `../../ortho-config/docs/users-guide.md`. Impact: it has been imported as + `docs/ortho-config-users-guide.md` and is now a local implementation + reference. + +## Decision log + +- Decision: apply hexagonal architecture only at meaningful volatility and + side-effect boundaries. Rationale: domain facts, locations, ordering, and + parse outcome classification need pure tests; `makefile-lossless`, CLI + parsing, filesystem access, and JSON output are adapters. Repositories, event + buses, CQRS layers, and adapter-to- adapter traits would add ceremony without + protecting a real boundary. Date/Author: 2026-07-13 / Codex planning team. +- Decision: define one domain-owned `MakefileParser` port and keep upstream CST + observations on the adapter side. Rationale: the young parser crate is the + principal volatile dependency. The port returns makeutil-owned facts and + diagnostics so upstream APIs cannot leak into schema or application policy. + Date/Author: 2026-07-13 / Codex planning team. +- Decision: use property testing for `LocationIndex`, not Kani or Verus. + Rationale: arbitrary UTF-8, newline layouts, and valid byte spans form a + natural generative invariant. There is no bounded concurrent/state machine + model for Kani and no introduced lemma or contractual business theorem that + would make a substantive Verus proof possible. Adding either would be + performative rather than rigorous. Date/Author: 2026-07-13 / Codex planning + team. +- Decision: provide JSON Schema Draft 2020-12 as a checked consumer artefact. + Rationale: schema version 1 is the stable integration contract and must be + independently machine-readable; Rust structs and snapshots alone are not an + adequate subprocess contract. Date/Author: 2026-07-13 / Codex planning team. +- Decision: use OrthoConfig 0.8.x for the `parse` subcommand while keeping input + selection explicit and unlayered. Rationale: the imported guide is the + requested CLI/configuration reference, but ADR-0001 allows no implicit path + or discovery. OrthoConfig supplies typed CLI derivation and preserves + help/version display exits; it must not add environment or file defaults for + `PATH` or `--stdin-filename`. Date/Author: 2026-07-13 / Codex planning team. +- Decision: preserve exact logical path spelling and use the complete + construct-range rules in `docs/design.md` section 6.2. Rationale: callers + need stable source slices and reproducible JSON. Deferring these choices + until adapter implementation would make plan approval meaningless and + accidentally turn upstream accessor choices into schema policy. Date/Author: + 2026-07-13 / Logisphere-reviewed Codex planning team. +- Decision: let the parser adapter return ordered makeutil-owned observations + and source spans; keep round-trip bytes in adapter tests only. Rationale: + location conversion, ordinals, hashing, and status are domain policy. + Upstream CST renderings and error types must not leak through the + domain-owned port. Date/Author: 2026-07-13 / Logisphere-reviewed Codex + planning team. +- Decision: serialize to memory before stdout and permit partial stdout only + when the operating system accepts a prefix before an output failure. + Rationale: the process can prevent serialization failures from writing JSON, + but cannot retract accepted bytes after a broken pipe or partial write. + Date/Author: 2026-07-13 / Logisphere-reviewed Codex planning team. + +## Outcomes & retrospective + +Planning is complete when this draft has passed deterministic documentation +gates, community-of-experts review, scrutineer CodeRabbit review, and is +available in a draft pull request. Implementation remains intentionally +unstarted until approval. During execution, update this section after every +milestone with observed behaviour, remaining gaps, and lessons. + +## Context and orientation + +The repository is a Rust 2024 application compiled on the pinned nightly with +Polonius. `src/lib.rs` contains a temporary `greet` function, `src/main.rs` +prints a greeting under a temporary lint exception, and `tests/stub.rs` is a +disposable test. Replace those stubs only after real tests establish the red +stage. + +[ADR-0001](../adrs/0001-single-file-gnu-make-parse.md) governs scope and the +stable subprocess contract. [The technical design](../design.md) defines JSON +fields, source locations, conditional flattening, determinism, security, and +the fixture classes. [The terms of reference](../terms-of-reference.md) govern +the larger problem boundary. [The repository layout](../repository-layout.md) +governs path ownership. + +Implementation must consult these practice guides at the relevant milestone: + +- [Rust testing with `rstest` fixtures](../rust-testing-with-rstest-fixtures.md) + for reusable and parameterized test setup. +- [`rstest-bdd` user's guide](../rstest-bdd-users-guide.md), specifically the + version 0.6.0-beta3 dependency and `#[scenario]` model, for feature tests. +- [Reliable testing via dependency injection](../reliable-testing-in-rust-via-dependency-injection.md) + for deterministic adapters and failure injection. +- [Rust doctest DRY guide](../rust-doctest-dry-guide.md) for public examples + shared with ordinary tests. +- [OrthoConfig user's guide](../ortho-config-users-guide.md), especially + “Subcommand configuration” and “Preserving `clap` display exits”, for the CLI + adapter. +- [Polonius migration](../polonius.md) before adding clones or ownership + workarounds. + +The implementing agent must load the `leta` skill for semantic navigation, the +`rust-router` skill to select only a necessary Rust specialist, the +`hexagonal-architecture` skill for boundary checks, and the `execplans` skill +to keep this document current. Use `firecrawl-mcp` only when an upstream API, +format, or prior-art gap remains after local documentation and exact dependency +source inspection. Use the `logisphere-experts` community for design reviews. + +The intended narrow dependency flow is: + +_Figure 1: Composition and dependency flow for the first parse slice._ + +```plaintext +CLI adapter ──> composition root ──> source reader + │ │ + └────────┬─────────┘ + v + parse application service + │ + v + domain-owned parser port + │ + v + makefile-lossless adapter + +parse report ──> composition root ──> JSON reporter ──> stdout / process exit +``` + +The domain owns schema-v1 value types, source locations, conditional ancestry, +global ordinal assignment, exact-byte SHA-256 metadata, diagnostic order, and +complete/recovered classification. The application service validates one source +byte buffer as UTF-8, hashes it, and coordinates its logical path with the +parser port. Adapters own OrthoConfig/clap, capability-oriented file or stdin +reading, upstream parsing into ordered observations, Serde serialization, +streams, and process exit. Adapters never call each other; `src/main.rs` is the +composition root. + +## Plan of work + +### Milestone 1: prove contracts and establish pure boundaries + +Start with a repository-wide Leta and text sweep for existing helpers, ports, +models, and fixture conventions. Record in `docs/design.md` the bounded +context, the single parser port's ownership and permitted caller, adapter +composition, error mapping, exact-pin exception, logical-path rule, and +source-range ownership. If this work changes ADR-0001 rather than clarifying +it, create a new ADR in the documented style and reference it from the design; +never silently rewrite an accepted decision. + +Add exact 0.3.40 to an adapter contract test and prove GNU variant selection, +complete and recovered trees, positioned diagnostics, every required accessor, +conditional branch traversal, range slices, and byte-for-byte render. Introduce +a compiling stub adapter first. The red test +`adapter_contract_reports_complete_rule_observations` must then fail an +assertion because the stub returns no observations, not because a symbol is +missing. If any required mapping is unavailable, stop under the upstream +tolerance before designing around it. + +Before schema types are implemented, build a go/no-go range matrix in +`docs/design.md` and contract tests for the complete rule, targets and +prerequisites, recipe including its tab and modifiers, variable directive, +include directive, conditional and else directive, positioned diagnostic, +line-derived diagnostic, EOF, CRLF, and continued lines. Each case asserts the +exact source slice specified in design section 6.2. Approval freezes those +semantics; Milestone 1 verifies that the exact upstream version can implement +them. + +Define a compact module layout, adjusting names only when the helper sweep +finds an existing convention: + +- `src/domain.rs` and focused children own `ParseReport`, `ParseStatus`, + `SourceLocation`, `ParseDiagnostic`, `RuleFact`, `RecipeFact`, `VariableFact`, + `IncludeFact`, `ConditionContext`, and `LocationIndex`. +- `src/ports.rs` owns the minimal `MakefileParser` trait and makeutil-owned + parser outcome/error types. +- `src/application.rs` owns `parse_source`, which accepts raw source bytes plus + a logical source name and parser port, maps invalid bytes to `source-utf8`, + and passes the validated `&str` to the parser port. +- `src/adapters/` owns the upstream parser, source input, CLI, and JSON + reporter. + +Do not create a file for each type. Keep cohesive types together and stay below +400 lines. Return semantic `thiserror` enums from library boundaries and format +stable fatal diagnostics explicitly in `main`; do not add an opaque error +dependency unless a later approved design decision establishes a concrete need. + +Add red `rstest` cases for location indexing, schema serialization order, +complete/recovered classification, and fixed exit classification. Add +`proptest` invariants: every valid generated byte span maps monotonically, +round-trips its byte slice, uses one-based display positions, and never splits +UTF-8; EOF and CRLF positions remain defined. Implement the smallest pure model +to make them green, then refactor. + +Add `schemas/makeutil.parse.v1.schema.json` using JSON Schema Draft 2020-12 and +tests that validate complete and recovered examples. Update `docs/design.md` +with the normative schema path. Define every required field, nullable field, +enum, integer minimum, the lower-case 64-hex SHA-256 pattern, fixed tool/parser +constants, array and diagnostic ordering, and always-emitted empty arrays. Apply +`additionalProperties: false` recursively. Self-validate the schema, validate +every snapshot, and reject malformed near-miss documents. + +Run the four required gates. A scrutineer then runs CodeRabbit. Resolve all +concerns, update this ExecPlan's evidence and decisions, and commit the +milestone before proceeding. + +### Milestone 2: collect source-faithful facts + +Build `makefile-lossless` adapter traversal behind `MakefileParser`. Traverse +root items in source order and iteratively flatten conditional arms while +carrying outer-to-inner `ConditionContext`. Generate global ordinals only after +source ordering is unambiguous. Translate all upstream ranges and diagnostics +at the adapter boundary. Never follow include paths or evaluate expressions. + +Create external fixtures under `tests/fixtures/makefiles/` for every class in +`docs/design.md` section 11.1. Use `rstest` parameterization rather than +copying test bodies. Cover multiple/repeated/double-colon rules, prerequisites, +continuations, all supported variable operators and flags, define blocks, +recipe prefixes, all four GNU conditional forms with `if` and `else` nesting, +literal/optional/dynamic includes, empty input, no trailing newline, CRLF, +multibyte UTF-8, recoverable syntax, large input, deep conditionals, and +hostile text. + +Use `googletest` matchers for membership, order, option, and error semantics and +`pretty_assertions` for full structured fact comparisons. Add `insta` +snapshots for at least one complete document, one recovered document with +multiple diagnostics, one nested-conditional document, and one document +containing every fact variant. Keep raw fixture input external to Rust source +files. + +For each family, run the focused test in red before its collector code, make +the minimal green change, and refactor only after the focused and wider adapter +suite pass. Round-trip every complete fixture through the exact upstream tree. +Recovered fixtures must always retain partial facts and classify as exit 1. + +Run the four gates, then scrutineer CodeRabbit review, concern resolution, +ExecPlan update, and an atomic commit. + +### Milestone 3: wire CLI, input, JSON, and process behaviour + +Use OrthoConfig 0.8.x and its clap integration to define one `parse` +subcommand. It accepts exactly one UTF-8 path token or `-`; stdin requires +`--stdin-filename`. Propagate clap `ArgMatches` through OrthoConfig's +`with_matches` path, or the equivalent 0.8.0 API, so only explicitly supplied +CLI values populate these fields. Do not enable configuration-file discovery or +environment fallbacks for either input. Add omitted-PATH and omitted- +`--stdin-filename` tests alongside stdin, help, and version cases. Keep +`src/main.rs` limited to composition, tracing initialization if diagnostics +need it, stream writes, and exit classification. + +Freeze exit and error mapping before wiring: help and version display exit 0 +using clap's normal display stream; usage errors, non-UTF-8 path arguments, +missing `--stdin-filename`, and conflicting stdin options use `cli` and exit 2; +open and read errors use `source-open` or `source-read` and exit 2; invalid +file bytes use `source-utf8` and exit 2; recovered parser diagnostics emit JSON +and exit 1; parser invariant failures use `parse-internal` and exit 2; +in-memory serialization uses `json-serialize` and exit 2; broken pipe or other +write failure uses `stdout-write` and exit 2. Panics are defects and are not +converted into stable diagnostics by a catch boundary. + +Implement capability-oriented path reading with `cap_std::fs_utf8` and +`camino`; keep a narrow stdin reader. Calculate SHA-256 over exact bytes, +reject invalid UTF-8 before parsing, and retain the caller-supplied logical +path without filesystem canonicalization. The JSON reporter writes one compact +document plus newline to stdout for complete and recovered results and writes +no progress prose. Fatal errors go to stderr. Serialization failures emit no +JSON; stdout-write failures may leave only the partial prefix described in +`docs/design.md` section 10.1. + +Add `tests/features/parse.feature` and Rust step bindings using `rstest-bdd` +0.6.0-beta3. Keep this specification synchronized with the tests: + +```gherkin +Feature: Parse one GNU Makefile into JSON facts + + Scenario: Parse a complete Makefile by path + Given a complete GNU Makefile fixture + When makeutil parses the fixture by path + Then stdout contains one schema version 1 JSON document + And the process exits with code 0 + And stderr is empty + + Scenario: Parse complete source from standard input + Given complete GNU Makefile source on standard input + When makeutil parses dash with stdin filename Makefile + Then the report source path is Makefile + And the process exits with code 0 + + Scenario: Report a recovered parse + Given a recoverable GNU Makefile fixture + When makeutil parses the fixture by path + Then stdout contains recovered facts and diagnostics + And the process exits with code 1 + + Scenario: Reject a missing input path + Given a path that does not exist + When makeutil attempts to parse the missing path + Then stdout is empty + And stderr reports the source-open operation + And the process exits with code 2 + + Scenario: Reject non UTF-8 source + Given source bytes that are not valid UTF-8 + When makeutil attempts to parse those bytes + Then stdout is empty + And stderr reports the source-utf8 operation + And the process exits with code 2 + + Scenario: Keep source-selected commands inert + Given a Makefile containing shell functions, recipes, assignments, and includes + When makeutil parses the hostile fixture + Then no sentinel side effect exists + And the process emits only source facts +``` + +Add black-box end-to-end tests that spawn the built binary with child-process +stdin and environment only. Test help/version, missing or extra input, missing +stdin filename, nonexistent paths, a directory supplied as a file, non-UTF-8 +file bytes, Unix non-UTF-8 path arguments, complete and recovered streams, +exact compact newline output, two byte-identical runs, and hostile source that +cannot create a sentinel. Prove include non-traversal separately with +`strace -f -e trace=openat,openat2` and assert that existing literal and +dynamic include paths never appear in file-open calls. + +Serialize the report fully into memory before output. Use an injected writer +seam to test a failure before any byte and a failure after a short partial +write. Broken pipe and all stdout write failures use operation identifier +`stdout-write` and exit 2; partial stdout is permitted only after an output +write failure. Use an injected reader for permission and mid-read failures, +rather than an unreliable unreadable-file E2E under privileged CI. + +Delete `greet`, the greeting `main`, its lint exception, and `tests/stub.rs` +only after replacement tests are green. Run the release-mode large/deep input +guardrail, the four gates, scrutineer CodeRabbit review, concern resolution, a +clean follow-up review, ExecPlan update, and an atomic commit. + +### Milestone 4: synchronize contracts and prove acceptance + +Rewrite `docs/users-guide.md` around `makeutil parse`: path and stdin examples, +field meanings, byte locations, stdout/stderr, exit 0/1/2, recovery, +unsupported scope, deterministic output, and the inert-input security +guarantee. Update `docs/contents.md` to index every ExecPlan and long-lived +document added by the work. Update `docs/developers-guide.md` with module +ownership, port/adapter rules, helper reuse policy, fixtures, snapshots, exact +parser upgrade gate, and the test-first workflow. Update +`docs/repository-layout.md` for source modules, `schemas/`, features, fixtures, +snapshots, and end-to-end tests. Reconcile ADR-0001 with the documentation +style guide and only move its status after required external evidence exists. + +Add a consumer-shaped test that deserializes representative schema-v1 JSON +without linking Rust implementation types. Record the command and result for an +actual Concordat subprocess trial when that repository is available. If it is +not available, leave the ADR Proposed and record the external gap. + +Run `make fmt` after documentation changes, followed by `make markdownlint` and +`make nixie`. If the Makefile changes, also run `mbake validate Makefile`. Then +run the four required gates. Scrutineer runs the final CodeRabbit review; clear +all concerns, update this plan and its retrospective, and commit. Do not mark +the plan COMPLETE until every acceptance criterion has current evidence. + +## Concrete steps + +Resolve and enter the repository root before running commands: + +```shell +cd "$(git rev-parse --show-toplevel)" +``` + +At the start of each implementation session, confirm scope and status: + +```shell +git status --short --branch +leta workspace add "$PWD" +leta files +``` + +For a focused Red-Green-Refactor cycle, replace the filters below with the new +test's actual module and scenario names and record exact output in `Progress`: + +```shell +RUSTFLAGS="-Zpolonius=next -D warnings" cargo test location_index --all-features +RUSTFLAGS="-Zpolonius=next -D warnings" cargo test parser_adapter --all-features +RUSTFLAGS="-Zpolonius=next -D warnings" cargo test --test parse_bdd --all-features +RUSTFLAGS="-Zpolonius=next -D warnings" cargo test --test parse_cli --all-features +``` + +The red run must fail because the new behaviour is absent, not because the test +does not compile for an unrelated reason. The corresponding green run must pass +without ignored or expected-failure markers. + +After every major milestone, run deterministic gates in this order: + +```shell +make check-fmt +make typecheck +make lint +make test +``` + +Expected successful endings include no warnings and exit status 0. Only after +all four pass may the scrutineer run: + +```shell +coderabbit review --agent +``` + +Resolve every applicable concern, rerun affected focused tests and all four +gates, rerun CodeRabbit to obtain a clean follow-up, update this document, then +commit the milestone. Never commit with a failing gate. Within a milestone, +make reviewable checkpoint commits after domain/schema, upstream contract, +rules/recipes, variables/includes/conditions, CLI/source, reporter/process, and +BDD/E2E units become independently green. Run CodeRabbit at the major milestone +boundary rather than on every checkpoint. + +For the documentation milestone, run: + +```shell +make fmt +make markdownlint +make nixie +``` + +If the milestone changes `Makefile`, also run `mbake validate Makefile`. + +The final manual acceptance exercise is: + +```shell +cargo build --bin makeutil +target/debug/makeutil parse tests/fixtures/makefiles/complete.mk +target/debug/makeutil parse tests/fixtures/makefiles/recovered.mk +printf 'all:\n\t@echo ok\n' | target/debug/makeutil parse --stdin-filename Makefile - +``` + +The second and fourth commands must emit one compact schema-v1 JSON line and +exit 0. The third command must emit recovered facts and diagnostics and exit 1. +Capture exit codes explicitly during implementation rather than relying on a +shell pipeline that hides them. + +Generate deterministic 1 MiB, 5 MiB, and 10 MiB valid rule fixtures with a +checked test helper, build release mode, warm each input once, then measure +three runs with `/usr/bin/time -v`. Record median elapsed time and maximum +resident set size in `Artefacts and notes`. Require the 10 MiB median to remain +under two seconds and maximum resident set size under 256 MiB on Linux, and +inspect the three sizes for super-linear growth. Exercise the generated +256-level conditional fixture in three consecutive release-mode measurements +with `/usr/bin/time -v`; require each Linux run to stay under 256 MiB maximum +resident set size and prove that iterative traversal does not overflow the +stack. On a non-Linux host, record that RSS is not comparable and retain +elapsed-time and correctness evidence. + +## Validation and acceptance + +Acceptance requires all ADR criteria plus the following evidence: + +- Unit tests prove every fact type, condition ancestry, ordinal ordering, + location edge case, classification, and error mapping. +- Property tests prove location monotonicity, valid end-exclusive ranges, + one-based display locations, and UTF-8 byte-column behaviour across generated + inputs. +- Adapter tests prove exact 0.3.40 mappings and byte-for-byte complete-tree + round trips for the full fixture corpus. +- `insta` snapshots and JSON Schema validation freeze complete, recovered, + nested, and all-variant JSON documents. +- BDD scenarios prove the user language of path, stdin, complete, recovered, + fatal, and inert parsing. +- End-to-end tests prove real binary arguments, stdin, streams, exits, trailing + newline, repeat determinism, and hostile source with no sentinel effect. +- A 10 MiB fixture and 256 nested conditionals remain inside the tolerance + guardrail without stack failure or uncontrolled allocation. +- `make check-fmt`, `make typecheck`, `make lint`, and `make test` pass at every + milestone and at final acceptance. +- `make markdownlint` and `make nixie` pass for documentation; + `mbake validate Makefile` passes if the Makefile changes. +- CodeRabbit reports no unresolved applicable concerns after deterministic + gates. +- A consumer-shaped JSON contract test passes. Actual Concordat subprocess + evidence is recorded before claiming cross-repository integration or moving + the ADR to Accepted. + +Red-Green-Refactor evidence must be appended to `Progress` for each milestone: +the exact red command and expected failure, the green command and pass, and the +post-refactor focused and wider gate results. + +## Idempotence and recovery + +Tests, formatters, schema validation, and documentation gates are repeatable. +Fixture and snapshot updates must be reviewed as contract changes, not accepted +blindly. `insta` pending files are diagnostic artefacts; inspect them, accept +only intended schema changes, and remove stale pending files before committing. + +If a milestone fails halfway, retain its red/green evidence in `Progress`, use +`git diff` and focused tests to identify the incomplete unit, and resume from +the last passing atomic commit. Do not use destructive Git commands. If an +adapter spike fails its go/no-go criterion, delete only the additive spike in a +separate reviewed change or retain it as documented evidence; do not conceal +the upstream limitation. + +Begin each milestone from a clean checkpoint or record exactly which reviewed, +uncommitted changes belong to it. If review fixes are intentionally +uncommitted, rerun their focused tests before resuming. Remove stale +`.snap.new` files only after comparing them with the approved schema; never +bulk-accept snapshots. + +## Artefacts and notes + +Firecrawl research used the authoritative 0.3.40 docs.rs source and tagged +upstream repository. It confirmed that the crate exports its GNU Make variant, +lossless `Makefile`, parse-result type, ordinary errors, positioned errors, +rules, recipes, variables, includes, conditionals, and Rowan ranges. During +Milestone 1, replace this planning summary with compile-checked signatures and +concise transcripts from the exact dependency. + +The Wyvern team independently found no existing abstraction to reuse and +recommended the same narrow parser-port boundary. The community-of-experts +review and scrutineer evidence must be appended here before this draft is +offered for approval. + +The scrutineer recorded passing `git diff --check`, Markdown and spelling, +Nixie, Rust formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, +nextest, and doctest gates. Three completed CodeRabbit rounds reported 11, 9, +and 7 actionable concerns respectively; all were addressed. A fourth post-fix +attempt was rejected before analysis by a recoverable rate limit with an +estimated 34-minute wait and emitted no findings. This is not represented as a +clean review result and should be retried before implementation begins. + +## Interfaces and dependencies + +The domain-facing shape should remain close to the following; exact fields must +match `docs/design.md` and the JSON Schema: + +```rust +pub trait MakefileParser { + fn parse(&self, source: &str) -> Result, ParseEngineError>; +} + +pub fn parse_source( + parser: &P, + logical_path: &Utf8Path, + source: &[u8], +) -> Result; +``` + +`SyntaxObservation` is makeutil-owned and carries one ordered syntax fact or +diagnostic with byte spans but no calculated line/column, ordinal, status, +upstream error, Rowan node, or rendered CST. The adapter enforces round-trip in +its contract tests. `parse_source` validates the bytes as UTF-8, hashes exact +bytes, calls the port with `&str`, and assigns locations, ordinals, diagnostic +order, and complete/recovered status. + +Planned runtime dependencies are: + +- `makefile-lossless = "=0.3.40"`, the explicitly approved exact exception; +- `ortho_config = "0.8.0"` for typed CLI derivation and display exits; +- `serde = "1.0.228"` with `derive` and `serde_json = "1.0.150"` for the owned + report contract; +- `sha2 = "0.11.0"` for exact-byte source identity; +- `camino = "1.2.4"`, `cap-std = "4.0.2"` with `fs_utf8`, and + `thiserror = "2.0.18"` for path, filesystem, and semantic error boundaries. + +Before adding each non-exception dependency, verify its current compatible +caret version and smallest necessary feature set. Preserve the approved exact +`makefile-lossless = "=0.3.40"` requirement unchanged. Do not add both a direct +`clap` dependency and OrthoConfig's re-exported surface unless the derive/API +contract requires it. + +Planned development dependencies are `rstest = "0.26.1"`, +`rstest-bdd = "0.6.0-beta3"`, `rstest-bdd-macros = "0.6.0-beta3"`, +`googletest = "0.14.3"`, `pretty_assertions = "1.4.1"`, `insta = "1.48.0"` with +JSON support, `proptest = "1.11.0"`, `jsonschema = "0.47.0"` with default +features disabled, `assert_cmd = "2.2.2"`, and `tempfile = "3.27.0"`. The +child-process tests must inspect raw exit codes and independent stdout/stderr. +Record selected features and the resolved `Cargo.lock` versions in milestone +evidence. No Kani or Verus dependency is planned for the reasons in the +Decision log. + +## Revision note + +Revised 2026-07-13 after Wyvern, Logisphere, and CodeRabbit review: freeze +path, range, schema, parser-port, failure-output, CLI merge, security, +performance, and dependency decisions; import and correct the OrthoConfig 0.8.0 +guide; and record deterministic and rate-limit evidence. No feature +implementation has begun. diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md new file mode 100644 index 0000000..7899cca --- /dev/null +++ b/docs/ortho-config-users-guide.md @@ -0,0 +1,1323 @@ +# OrthoConfig user's guide + +`OrthoConfig` is a Rust library that unifies command‑line arguments, +environment variables and configuration files into a single, strongly typed +configuration struct. It is inspired by tools such as `esbuild` and is designed +to minimize boiler‑plate. The library uses `serde` for deserialization and +`clap` for argument parsing, while `figment` provides layered configuration +management. This guide covers the functionality currently implemented in the +repository. + +## Core concepts and motivation + +Rust projects often wire together `clap` for CLI parsing, `serde` for +de/serialization, and ad‑hoc code for loading `*.toml` files or reading +environment variables. Mapping between different naming conventions (kebab‑case +flags, `UPPER_SNAKE_CASE` environment variables, and `snake_case` struct +fields) can be tedious. `OrthoConfig` addresses these problems by letting +developers describe their configuration once and then automatically loading +values from multiple sources. The core features are: + +- **Layered configuration** – Configuration values can come from application + defaults, configuration files, environment variables and command‑line + arguments. Later sources have higher precedence, while each field's merge + strategy decides whether the higher layer replaces or combines values. + Command‑line arguments have the highest precedence and defaults the lowest. + +- **Orthographic naming** – A single field in a Rust struct is automatically + mapped to a CLI flag (kebab‑case), an environment variable (upper snake case + with a prefix), and a file key (snake case). This removes the need for manual + aliasing. + +- **Type‑safe deserialization** – Values are deserialized into strongly typed + Rust structs using `serde`. + +- **Easy adoption** – A procedural macro `#[derive(OrthoConfig)]` adds the + necessary code. Developers only need to derive `serde` traits on their + configuration struct and call a generated method to load the configuration. + +- **Customizable behaviour** – Attributes such as `default`, `cli_long`, + `cli_short`, and `merge_strategy` provide fine‑grained control over naming + and merging behaviour. +- **Declarative merge tooling** – In OrthoConfig 0.8.0, every derived + configuration struct exposes the public `merge_from_layers` helper, and the + crate publicly exports `MergeComposer`. Together they compose defaults, + files, environment captures, and CLI values without instantiating the CLI + parser. Vector fields honour the append strategy by default, while maps use + keyed merging unless replacement is requested. + +The workspace bundles an executable Hello World example under +`examples/hello_world`. It layers defaults, environment variables, and CLI +flags via the derive macro; see its [README](../examples/hello_world/README.md) +for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven +Development) scenarios that validate behaviour end-to-end. + +Run `make test` to execute the example’s coverage. The unit suite uses `rstest` +fixtures to exercise parsing, validation, and command planning across +parameterized edge-cases (conflicting delivery modes, blank salutations, and +custom punctuation). Behavioural coverage comes from the `rstest-bdd` +integration test under `tests/rstest_bdd`, which spawns the compiled binary +inside a temporary working directory, layers `.hello_world.toml` defaults via +`cap-std`, and sets `HELLO_WORLD_*` environment variables per scenario to +demonstrate precedence: configuration files < environment variables < CLI +arguments. Scenarios tagged `@requires.yaml` are gated by compile-time tag +filters, so non-`yaml` builds skip them automatically. + +`ConfigDiscovery` exposes the same search order used by the example so +applications can replace bespoke path juggling with a single call. By default +the helper honours `HELLO_WORLD_CONFIG_PATH`, then searches +`$XDG_CONFIG_HOME/hello_world`, each entry in `$XDG_CONFIG_DIRS` (falling back +to `/etc/xdg` on Unix-like targets), Windows application data directories, +`$HOME/.config/hello_world`, `$HOME/.hello_world.toml`, and finally the project +root. Candidates are deduplicated in precedence order (case-insensitively on +Windows). Call `utf8_candidates()` to receive a `Vec` +without manual conversions: + +```rust,no_run +use ortho_config::ConfigDiscovery; + +# fn load() -> ortho_config::OrthoResult<()> { +let discovery = ConfigDiscovery::builder("hello_world") + .env_var("HELLO_WORLD_CONFIG_PATH") + .build(); + +if let Some(figment) = discovery.load_first()? { + // Extract your configuration struct from the figment here. + let _config = figment; +} else { + // Fall back to defaults when no configuration files exist. +} +# Ok(()) +# } +``` + +The repository ships `config/overrides.toml`, which extends +`config/baseline.toml` to set `is_excited = true`, provide a `Layered hello` +preamble, and swap the greet punctuation for `!!!`. Behavioural tests and demo +scripts assert the uppercase output to guard this layering. + +### Declarative merging + +The 0.8.0 derive macro emits the public `merge_from_layers` helper for +composing configuration layers without going through Figment directly. The +0.8.0 runtime crate publicly exports `MergeComposer`, which collects +`MergeLayer` instances for defaults, files, environment, and CLI input. Pass +its layers to the derived helper to build the final struct: + +```rust +use ortho_config::{MergeComposer, OrthoConfig}; +use ortho_config::json; +use serde::Deserialize; + +#[derive(Debug, Deserialize, OrthoConfig)] +struct AppConfig { + recipient: String, + salutations: Vec, +} + +let mut composer = MergeComposer::new(); +composer.push_defaults(json!({"recipient": "Defaults", "salutations": ["Hi"] })); +composer.push_environment(json!({"salutations": ["Env"] })); +composer.push_cli(json!({"recipient": "Cli" })); + +let merged = AppConfig::merge_from_layers(composer.layers())?; +assert_eq!(merged.recipient, "Cli"); +assert_eq!( + merged.salutations, + vec![String::from("Hi"), String::from("Env")] +); +``` + +This API surfaces the same precedence as the generated `load()` method while +making it trivial to drive unit and behavioural tests with hand-crafted layers. +`Vec<_>` fields accumulate values from each layer in order, so defaults can +coexist with environment or CLI extensions. This general `merge_from_layers` +behaviour is distinct from the Hello World example's `load_global_config` +helper-specific CLI reset described below. The example’s behavioural suite +includes a dedicated scenario that parses JSON descriptors into `MergeLayer` +values and asserts the merged configuration via these helpers. Unit tests can +mirror this approach with `rstest` fixtures: define fixtures for default +payloads, then enumerate cases for file, environment, and CLI layers. This +validates every precedence permutation without copy-pasting setup. + +Every derived configuration also exposes `compose_layers()` and +`compose_layers_from_iter(...)`. These helpers discover configuration files, +serialize environment variables, and capture CLI input as a `LayerComposition`, +keeping discovery separate from merging. The returned composition includes both +the ordered layers and any collected errors, letting callers push additional +layers or aggregate errors before invoking `merge_from_layers`. + +### Post-merge hooks + +Some configuration structs require custom adjustments after the standard merge +pipeline completes. The `PostMergeHook` trait provides an opt-in hook that the +library invokes automatically when the `#[ortho_config(post_merge_hook)]` +attribute is present. + +```rust +use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "APP_", post_merge_hook)] +struct GreetArgs { + #[ortho_config(default = String::from("!"))] + punctuation: String, + preamble: Option, +} + +impl PostMergeHook for GreetArgs { + fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> { + // Normalize whitespace-only preambles to None + if self.preamble.as_ref().is_some_and(|p| p.trim().is_empty()) { + self.preamble = None; + } + Ok(()) + } +} +``` + +The `PostMergeContext` provides metadata about the merge process: + +- `prefix()` – the environment variable prefix used during loading +- `loaded_files()` – paths of configuration files that contributed to the merge +- `has_cli_input()` – whether CLI arguments were present in the merge + +Use post-merge hooks sparingly. Most configuration needs are satisfied by the +standard merge pipeline combined with field-level attributes like +`cli_default_as_absent` and `merge_strategy`. Hooks are best suited for: + +- Normalizing values after all layers have been applied +- Performing validation that depends on multiple fields being merged +- Conditional transformations based on which sources contributed + +The Hello World example demonstrates this pattern with `GreetCommand`, which +uses a post-merge hook to clean up whitespace-only preambles. + +### Localizing CLI copy + +`ortho_config` exposes a `Localizer` trait, so applications can swap the text +`clap` displays without abandoning sensible defaults. Each implementation is +`Send + Sync` and returns owned `String` instances, making it cheap to cache +resolved messages or fall back to the stock help text. The helper type +`LocalizationArgs<'a> = HashMap<&'a str, FluentValue<'a>>` mirrors Fluent’s +placeholder model, keeping argument-aware lookups ergonomic. + +The crate now ships a Fluent-backed implementation. `FluentLocalizer` embeds an +English catalogue at `locales/en-US/messages.ftl`, layers any consumer bundles +over those defaults, logs formatting errors with `tracing`, and falls back to +the next bundle when a lookup fails: + +```rust +use ortho_config::{langid, FluentLocalizer, LocalizationArgs, Localizer}; + +static APP_EN: &str = include_str!("../locales/en-US/app.ftl"); + +let localizer = FluentLocalizer::builder(langid!("en-US")) + .with_consumer_resources([APP_EN]) + .try_build() + .expect("embedded locales load successfully"); + +let mut args: LocalizationArgs<'_> = LocalizationArgs::default(); +args.insert("binary", "demo".into()); +assert_eq!( +localizer + .lookup("cli.usage", Some(&args)) + .expect("usage copy exists"), + "Usage: demo [OPTIONS] " +); +``` + +Applications can inject a custom logger with `with_error_reporter` when they +need to capture Fluent formatting errors alongside command parsing failures. + +The Hello World example ships `hello_world::localizer::DemoLocalizer`, which +builds a `FluentLocalizer` from `examples/hello_world/locales/en-US` and drives +`CommandLine::command().localize(&localizer)` and +`CommandLine::try_parse_localized_env`. If the localization setup ever fails, +the example falls back to `NoOpLocalizer`, preserving the stock `clap` strings +until translations are fixed. + +Errors surfaced by `clap` can be localized as well. Use +`localize_clap_error_with_command` to map each `ErrorKind` to a Fluent +identifier of the form `clap-error-`, forwarding argument context +such as the missing flag or the offending value. Supplying the command enables +the helper to populate missing context (for example, the available subcommands +when `clap` emits `DisplayHelpOnMissingArgumentOrSubcommand`). When no +translation exists, the helper returns the original `clap` error unchanged: + +```rust +use clap::{CommandFactory, FromArgMatches}; +use ortho_config::{localize_clap_error_with_command, Localizer}; + +# #[derive(clap::Parser)] +# struct Cli {} +fn parse(localizer: &dyn Localizer) -> Result { + let mut command = Cli::command().localize(localizer); + let mut matches = command + .try_get_matches() + .map_err(|err| { + localize_clap_error_with_command(err, localizer, Some(&command)) + })?; + + Cli::from_arg_matches_mut(&mut matches).map_err(|err| { + let err = err.with_cmd(&command); + localize_clap_error_with_command(err, localizer, Some(&command)) + }) +} +``` + +## Installation and dependencies + +Add `ortho_config` as a dependency in `Cargo.toml` along with `serde`: + +```toml +[dependencies] +ortho_config = "0.8.0" # replace with the latest version +serde = { version = "1.0", features = ["derive"] } +clap = { version = "4", features = ["derive"] } # required for CLI support +``` + +By default, only TOML configuration files are supported. To enable JSON5 +(`.json` and `.json5`) and YAML (`.yaml` and `.yml`) support, enable the +corresponding cargo features: + +```toml +[dependencies] +ortho_config = { version = "0.8.0", features = ["json5", "yaml"] } +# Enabling these features expands file formats; precedence stays: defaults < file < env < CLI. +``` + +Enabling the `json5` feature causes both `.json` and `.json5` files to be +parsed using the JSON5 format. Without this feature, these files are ignored +during discovery and do not cause errors if present. The `yaml` feature +similarly enables `.yaml` and `.yml` files; without it, such files are skipped +during discovery and do not cause errors if present. + +`ortho_config` re-exports its parsing dependencies, so consumers do not need to +declare them directly. Access `figment`, `uncased`, `xdg` (on Unix-like and +Redox targets), and the optional parsers (`figment_json5`, `json5`, +`serde_saphyr`, `toml`) via `ortho_config::` paths. The `serde_json` re-export +is enabled by default because the crate relies on it internally; disable +default features only when explicitly opting back into `serde_json`. + +### Dependency architecture for derive macro users + +The `#[derive(OrthoConfig)]` macro emits fully qualified paths rooted at +`ortho_config`. For example, generated code references +`ortho_config::figment::Figment` and `ortho_config::uncased::Uncased` rather +than `figment::...` or `uncased::...`. Those paths resolve because +`ortho_config` re-exports these crates. + +For screen readers: The following diagram shows that generated code references +re-exported crates through `ortho_config`, so consumer crates can rely on the +runtime crate dependency. + +```mermaid +flowchart TD + A[Consumer crate] -->|depends on| B[ortho_config] + C[derive OrthoConfig] -->|generates| D[ortho_config::figment::...] + C -->|generates| E[ortho_config::uncased::...] + B -->|re-exports| F[figment] + B -->|re-exports| G[uncased] + B -->|re-exports on Unix/Redox| H[xdg] +``` + +_Figure 1: Derive output resolves parser crates through `ortho_config`._ + +In the common case, `Cargo.toml` does not need direct `figment`, `uncased`, or +`xdg` dependencies: + +```toml +[dependencies] +ortho_config = "0.8.0" +serde = { version = "1.0", features = ["derive"] } +clap = { version = "4", features = ["derive"] } +``` + +### Troubleshooting dependency errors + +- If source code imports `figment`, `uncased`, or `xdg` directly, either switch + imports to `ortho_config::figment` / `ortho_config::uncased` / + `ortho_config::xdg`, or keep explicit dependencies for that direct usage. +- If derive output fails with unresolved `ortho_config::...` paths, ensure the + dependency key is named `ortho_config` in `Cargo.toml` or use the + `#[ortho_config(crate = "...")]` attribute to specify the alias. +- **Dependency aliasing** is supported via the `crate` attribute. When + renaming the dependency in `Cargo.toml` (for example, + `my_cfg = { package = "ortho_config", ... }`), add + `#[ortho_config(crate = "my_cfg")]` to the struct so generated code + references the correct crate path. +- If dependency resolution reports conflicts, inspect duplicates with + `cargo tree -d` and prefer the versions selected through `ortho_config` + unless direct usage requires something else. + +### FAQ: should `figment`, `uncased`, or `xdg` be direct dependencies? + +No for derive-generated code. Yes, only when application code directly imports +those crates without going through the `ortho_config::` re-exports. + +YAML parsing is handled by the pure-Rust `serde-saphyr` crate. It adheres to +the YAML 1.2 specification, so unquoted scalars such as `yes`, `on`, and `off` +remain strings. The provider enables `Options::strict_booleans`, ensuring only +`true` and `false` deserialize as booleans, while legacy YAML 1.1 literals are +treated as plain strings. Duplicate mapping keys surface as parsing errors +instead of silently accepting the last entry, helping catch typos early. + +## Migrating from earlier versions + +Projects using a pre‑0.5 release can upgrade with the following steps: + +- `#[derive(OrthoConfig)]` remains the correct way to annotate configuration + structs. No additional derives are required. +- Remove any `load_with_reference_fallback` helpers. The merge logic inside + `load_and_merge_subcommand_for` supersedes this workaround. +- Replace calls to deprecated helpers such as `load_subcommand_config_for` with + `ortho_config::subcommand::load_and_merge_subcommand_for` or import + `ortho_config::SubcmdConfigMerge` to call `load_and_merge` directly. + +Import it with: + +```rust +use ortho_config::SubcmdConfigMerge; +``` + +Subcommand structs can leverage the `SubcmdConfigMerge` trait to expose a +`load_and_merge` method automatically: + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use ortho_config::SubcmdConfigMerge; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +struct PrArgs { + reference: String, +} + +# fn demo(pr_args: &PrArgs) -> OrthoResult<()> { +let merged = pr_args.load_and_merge()?; +# let _ = merged; +# Ok(()) +# } +``` + +After parsing the relevant subcommand struct, call `load_and_merge()?` on that +value (for example, `pr_args.load_and_merge()?`) to obtain the merged +configuration for that subcommand. + +## Defining configuration structures + +A configuration is represented by a plain Rust struct. To take advantage of +`OrthoConfig`, derive the following traits: + +- `serde::Deserialize` and `serde::Serialize` – required for deserializing + values and merging overrides. + +- The derive macro generates a hidden `clap::Parser` implementation, so + manual `clap` annotations are not required in typical use. CLI customization + is performed using `ortho_config` attributes such as `cli_short`, or + `cli_long`. + +- `OrthoConfig` – provided by the library. This derive macro generates the code + to load and merge configuration from multiple sources. + +Optionally, the struct can include a `#[ortho_config(prefix = "PREFIX")]` +attribute. The prefix sets a common string for environment variables and +configuration file names. When the attribute omits a trailing underscore, +`ortho_config` appends one automatically so environment variables consistently +use `_`. Trailing underscores are trimmed and the prefix is lower‑cased +when used to form file names. For example, a prefix of `APP` results in +environment variables like `APP_PORT` and file names such as `.app.toml`. + +### Field-level attributes + +Field attributes modify how a field is sourced or merged: + +| Attribute | Behaviour | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default = expr` | Supplies a default value when no source provides one. The expression can be a literal or a function path. | +| `cli_long = "name"` | Overrides the automatically generated long CLI flag (kebab-case). | +| `cli_short = 'c'` | Adds a single-letter short flag for the field. | +| `merge_strategy = "append"` | For `Vec` fields, concatenates values from successive sources. This is the vector default. | +| `merge_strategy = "replace"` | For `Vec` or `BTreeMap` fields, replaces the lower-precedence collection with the higher-precedence value. | +| `merge_strategy = "keyed"` | For `BTreeMap` fields, merges entries by key. This is the map default and is not supported for vectors. | +| `cli_default_as_absent` | Treats typed clap defaults (`default_value_t`, `default_values_t`) as absent during configuration merging. File and environment values take precedence, while explicit CLI overrides still win. | + +Unrecognized keys are ignored by the derive macro for forwards compatibility. +Unknown keys will therefore silently do nothing. Developers who require +stricter validation may add manual `compile_error!` guards. + +Vector append buffers operate on raw JSON values, so element types only need to +implement `serde::Deserialize`. Deriving `serde::Serialize` remains useful when +applications serialize configuration back out (for example, to emit defaults), +but it is no longer required merely to opt into the append strategy. + +By default, each field receives a long flag derived from its name in kebab‑case +and a short flag. The macro chooses the short flag using these rules: + +- Use the field's first ASCII alphanumeric character. +- If that character is already taken or reserved, try its uppercase form. +- If both are unavailable, no short flag is assigned; specify `cli_short` to + resolve the collision. + +| Scenario | Result | +| --------------------------------- | ---------------------- | +| First letter free | `-p` | +| Lowercase taken; uppercase free | `-P` | +| Both cases taken | none (set `cli_short`) | +| Explicit override via `cli_short` | `-r` | + +Collisions are evaluated against short flags already assigned within the same +parser, and reserved characters such as clap's `-h` and `-V`. A character is +considered taken if it matches either set. + +The macro does not scan other characters in the field name when deriving the +short flag. Short flags must be single ASCII alphanumeric characters and may +not use clap's global `-h` or `-V` options. Long flags must contain only ASCII +alphanumeric characters or hyphens, must not start with `-`, cannot be named +`help` or `version`, and the macro rejects underscores. + +For example, when multiple fields begin with the same character, `cli_short` +can disambiguate the final field: + +```rust +#[derive(OrthoConfig)] +struct Options { + port: u16, // -p + path: String, // -P + #[ortho_config(cli_short = 'r')] + peer: String, // -r via override +} +``` + +### Example configuration struct + +The following example illustrates many of these features: + +```rust + use ortho_config::{OrthoConfig, OrthoError}; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] + // env vars use APP_ (the macro adds the underscore automatically) + #[ortho_config(prefix = "APP")] + struct AppConfig { + /// Logging verbosity + log_level: String, + + /// Port to bind on – defaults to 8080 when unspecified + #[ortho_config(default = 8080)] + port: u16, + + /// Optional list of features. Values from files, environment and CLI are appended. + #[ortho_config(merge_strategy = "append")] + features: Vec, + + /// Nested configuration for the database. A separate prefix is used to avoid ambiguity. + #[serde(flatten)] + database: DatabaseConfig, + + /// Enable verbose output; also available as -v via cli_short + #[ortho_config(cli_short = 'v')] + verbose: bool, + } + +#[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "DB")] // used in conjunction with APP_ prefix to form APP_DB_URL +struct DatabaseConfig { + url: String, + + #[ortho_config(default = 5)] + pool_size: Option, +} + +fn main() -> Result<(), OrthoError> { + // Parse CLI arguments and merge with defaults, file and environment + let config = AppConfig::load()?; + println!("Final config: {:#?}", config); + Ok(()) +} +``` + +`clap` attributes are not required in general; flags are derived from field +names and `ortho_config` attributes. In this example, the `AppConfig` struct +uses a prefix of `APP`. The `DatabaseConfig` struct declares a prefix `DB`, +resulting in environment variables such as `APP_DB_URL`. The `features` field +is a `Vec` and accumulates values from multiple sources rather than +overwriting them. + +### Customizing configuration discovery + +Configuration discovery can be tailored per struct using the `discovery(...)` +attribute. The keys recognized today include: + +- `app_name`: directory name used under XDG and application data folders. +- `env_var`: override for the environment variable consulted before discovery + runs (defaults to `CONFIG_PATH`). +- `config_file_name`: primary filename searched in platform-specific + configuration directories (defaults to `config.toml`). +- `dotfile_name`: dotfile name consulted in the current working directory and + the user's home directory. +- `project_file_name`: filename searched within project roots (defaults to the + dotfile name). +- `config_cli_long` / `config_cli_short`: rename the CLI flag used to provide an + explicit configuration path. +- `config_cli_visible`: when `true`, the generated CLI flag appears in help + output instead of remaining hidden. + +Supplying only the required keys allows the CLI flag to be renamed without +altering file discovery, or vice versa. When the attribute is omitted, the +defaults described in [Config path override](#config-path-override) continue to +apply. + +## Loading configuration and precedence rules + +### How loading works + +The `load_from_iter` method (used by the convenience `load`) performs the +following steps: + +1. Builds a `figment` configuration profile. A defaults provider constructed + from the `#[ortho_config(default = …)]` attributes is added first. + +2. Attempts to load a configuration file. Candidate file paths are searched in + the following order: + + 1. If provided, a path supplied via the CLI flag generated by the + `discovery(...)` attribute (which defaults to a hidden `--config-path`) + or the `CONFIG_PATH` environment variable (for example, + `APP_CONFIG_PATH` or `CONFIG_PATH`) takes precedence; see + [Config path override](#config-path-override). + + 2. A dotfile named `..toml` in the current working directory. + + 3. A dotfile of the same name in the user's home directory. + + 4. On Unix‑like systems, the XDG configuration directory (e.g. + `~/.config/app/config.toml`) is searched using the `xdg` crate; on + Windows, the `%APPDATA%` and `%LOCALAPPDATA%` directories are checked. + + 5. If the `json5` or `yaml` features are enabled, files with `.json`, + `.json5`, `.yaml`, or `.yml` extensions are also considered in these + locations. + +3. Adds an environment provider using the prefix specified on the struct. Keys + are upper‑cased and nested fields use double underscores (`__`) to separate + components. + +4. Adds a provider containing the CLI values (captured as `Option` fields) + as the final layer. + +5. Merges vector fields according to the `merge_strategy` (currently only + `append`) so that lists of values from lower precedence sources are extended + with values from higher precedence ones. + +6. Attempts to extract the merged configuration into the concrete struct. On + success it returns the completed configuration; otherwise an `OrthoError` is + returned. + +### Config path override + +The derive macro always recognizes a configuration override flag and the +associated environment variables even when the configuration struct does not +declare a field explicitly. By default a hidden `--config-path` flag is +accepted alongside `CONFIG_PATH` and the unprefixed `CONFIG_PATH`. +Applying the struct-level `discovery(...)` attribute customizes this behaviour, +allowing you to rename or expose the CLI flag and adjust the filenames searched +during discovery: + +```rust +use serde::Deserialize; + +#[derive(Debug, Deserialize, ortho_config::OrthoConfig)] +#[ortho_config( + prefix = "APP_", + discovery( + app_name = "demo", + env_var = "DEMO_CONFIG_PATH", + config_file_name = "demo.toml", + dotfile_name = ".demo.toml", + project_file_name = ".demo.toml", + config_cli_long = "config", + config_cli_short = 'c', + config_cli_visible = true, + ) +)] +struct CliArgs { + #[ortho_config(default = 8080)] + port: u16, +} +``` + +The snippet above exposes a visible `--config`/`-c` flag, renames the +environment override to `DEMO_CONFIG_PATH`, and instructs discovery to search +for `demo.toml` (and `.demo.toml`) within the standard directories. Omitting +`config_cli_visible` keeps the flag hidden while still parsing it, and leaving +`config_cli_short` unset skips the short alias. When the `discovery(...)` +attribute is absent, the defaults—hidden `--config-path`, `CONFIG_PATH` +and `CONFIG_PATH`, and the automatically derived dotfile names—remain in effect. + +### Source precedence + +Values are loaded from each layer in a specific order. Later layers override +earlier ones. The precedence, from lowest to highest, is: + +1. **Application‑defined defaults** – values provided via `default` attributes + or `Option` fields are considered defaults. + +2. **Configuration file** – values from a TOML (or JSON5/YAML) file loaded from + one of the paths listed above. + +3. **Environment variables** – variables prefixed with the struct's `prefix` + (e.g. `APP_PORT`, `APP_DATABASE__URL`) override file values. + +4. **Command‑line arguments** – values parsed by `clap` override all other + sources. + +Nested structs are flattened in the environment namespace by joining field +names with double underscores. For example, if `AppConfig` has a nested +`database` field and the prefix is `APP`, then `APP_DATABASE__URL` sets the +`database.url` field. If a nested struct has its own prefix attribute, that +prefix is used for its fields (e.g. `APP_DB_URL`). + +When `clap`'s `flatten` attribute is employed to compose argument groups, the +flattened struct is initialized even if no CLI flags within the group are +specified. During merging, `ortho_config` discards these empty groups so that +values from configuration files or the environment remain in place unless a +field is explicitly supplied on the command line. + +### Using defaults and optional fields + +Fields of type `Option` are treated as optional values. If no source +provides a value for an `Option` field then it remains `None`. To provide a +default value for a non‑`Option` field or for an `Option` field that should +have an initial value, specify `#[ortho_config(default = expr)]`. This default +acts as the lowest‑precedence source and is overridden by file, environment or +CLI values. + +### Environment variable naming + +Environment variables are upper‑cased and use underscores. The struct‑level +prefix (if supplied) is prepended without any separator, and nested fields are +separated by double underscores. For the `AppConfig` and `DatabaseConfig` +example above, valid environment variables include `APP_LOG_LEVEL`, `APP_PORT`, +`APP_DATABASE__URL` and `APP_DATABASE__POOL_SIZE`. If the nested struct has its +own prefix (`DB`), then the environment variable becomes `APP_DB_URL`. + +Comma-separated values such as `DDLINT_RULES=A,B,C` are parsed as lists. The +loader converts these strings into arrays before merging, so array fields +behave the same across environment variables, CLI arguments and configuration +files. Values containing literal commas must be wrapped in quotes or brackets +to disable list parsing. + +## Configuration inheritance + +A configuration file may specify an `extends` key pointing to another file. The +referenced file is loaded first and the current file's values override it. The +path is resolved relative to the file containing the `extends` directive. +Missing files raise a not-found error that includes both the resolved absolute +path and the file that declared `extends`, making it clear what needs to be +created. Precedence across all sources becomes base file → extending file → +environment variables → CLI flags. Cycles are detected and reported via a +`CyclicExtends` error. Prefix handling and subcommand namespaces work as normal +when inheritance is in use. + +## Dynamic rule tables + +Map fields such as `BTreeMap` allow configuration files to +declare arbitrary rule keys. Any table nested under `rules.` is +deserialized into the map without prior knowledge of the key names. This +enables use cases like: + +```toml +[rules.consistent-casing] +enabled = true +[rules.no-tabs] +enabled = false +``` + +Each entry becomes a map key with its associated struct value. + +## Ignore patterns + +Lists of files or directories to exclude can be specified via comma-separated +environment variables and CLI flags. Values are merged using the `append` +strategy, so that configuration defaults are extended by environment variables +and finally by the CLI. Whitespace around entries is trimmed and duplicates are +preserved. For example: + +```bash +DDLINT_IGNORE_PATTERNS=".git/,build/" +mytool --ignore-patterns target/ +``` + +results in `ignore_patterns = [".git/", "build/", "target/"]`. + +By default, the ignore-pattern list includes `[".git/", "build/", "target/"]`. +These defaults are extended (not replaced) by environment variables and CLI +flags via the `append` merge strategy. + +## Subcommand configuration + +Many CLI applications use `clap` subcommands to perform different operations. +`OrthoConfig` supports per‑subcommand defaults via a dedicated `cmds` +namespace. The helper function `load_and_merge_subcommand_for` loads defaults +for a specific subcommand and merges them beneath the CLI values. The merged +struct is returned as a new instance; the original `cli` struct remains +unchanged. CLI fields left unset (`None`) do not override environment or file +defaults, avoiding accidental loss of configuration. + +### How it works + +When a struct derives `OrthoConfig`, it also implements the associated +`prefix()` method. This method returns the configured prefix string. +`load_and_merge_subcommand_for(prefix, cli_struct)` uses this prefix to build a +`cmds.` section name for the configuration file and an +`PREFIX_CMDS_SUBCOMMAND_` prefix for environment variables. Configuration is +loaded in the same order as global configuration (defaults → file → environment +→ CLI), but only values in the `[cmds.]` section or environment +variables beginning with `PREFIX_CMDS__` are considered. + +### Example + +Suppose an application has a `pr` subcommand that accepts a `reference` +argument and a `repo` global option. With `OrthoConfig` the argument structures +might be defined as follows: + +```rust +use clap::Parser; +use ortho_config::OrthoConfig; +use ortho_config::SubcmdConfigMerge; +use serde::{Deserialize, Serialize}; + +#[derive(Parser, Deserialize, Serialize, Debug, OrthoConfig, Clone, Default)] +#[ortho_config(prefix = "VK")] // all variables start with VK +pub struct GlobalArgs { + pub repo: Option, +} + +#[derive(Parser, Deserialize, Serialize, Debug, OrthoConfig, Clone, Default)] +#[ortho_config(prefix = "VK")] // subcommands share the same prefix +pub struct PrArgs { + pub reference: Option, +} + +fn main() -> Result<(), Box> { + let cli_pr = PrArgs::parse(); + // Merge defaults from [cmds.pr] and VK_CMDS_PR_* over CLI + let merged_pr = cli_pr.load_and_merge()?; + let reference = merged_pr + .reference + .as_deref() + .ok_or("reference must be supplied by CLI, configuration, or environment")?; + println!("PR reference: {reference}"); + Ok(()) +} +``` + +A configuration file might include: + +```toml +[cmds.pr] +reference = "https://github.com/leynos/mxd/pull/31" + +[cmds.issue] +reference = "https://github.com/leynos/mxd/issues/7" +``` + +and environment variables could override these defaults: + +```bash +VK_CMDS_PR_REFERENCE=https://github.com/owner/repo/pull/42 +VK_CMDS_ISSUE_REFERENCE=https://github.com/owner/repo/issues/101 +``` + +Within the `vk` example repository, the global `--repo` option is provided via +the `GlobalArgs` struct. A developer can set this globally using the +environment variable `VK_REPO` without passing `--repo` on every invocation. +Subcommands `pr` and `issue` load their defaults from the `cmds` namespace and +environment variables. If the `reference` field is missing in the defaults, the +tool continues using the CLI value instead of exiting with an error. + +### Merging a selected subcommand enum + +When the root CLI parses into a `Commands` enum, it is possible to derive +`ortho_config_macros::SelectedSubcommandMerge` and import the +`SelectedSubcommandMerge` trait from `ortho_config` to merge the selected +variant in one call, instead of matching only to call `load_and_merge()` per +branch. + +Variants that rely on `cli_default_as_absent` (because they use +`default_value_t`) should be annotated with `#[ortho_subcommand(with_matches)]` +so the merge can consult `ArgMatches` and treat clap defaults as absent. + +To load the global configuration and merge the selected subcommand in one +expression, use `load_globals_and_merge_selected_subcommand` and supply a +global loader as a closure. + +```rust,no_run +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; +use ortho_config::{OrthoConfig, load_globals_and_merge_selected_subcommand}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Parser)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand, ortho_config_macros::SelectedSubcommandMerge)] +enum Commands { + #[ortho_subcommand(with_matches)] + Greet(GreetArgs), + Run(RunArgs), +} + +#[derive(Debug, Default, Deserialize, Serialize, Parser, OrthoConfig)] +#[command(name = "greet")] +#[ortho_config(prefix = "APP_")] +struct GreetArgs { + #[arg(long, default_value = "!")] + #[ortho_config(cli_default_as_absent)] + punctuation: String, +} + +#[derive(Debug, Default, Deserialize, Serialize, Parser, OrthoConfig)] +#[command(name = "run")] +#[ortho_config(prefix = "APP_")] +struct RunArgs { + #[arg(long)] + option: Option, +} + +fn main() -> Result<(), Box> { + let command = Cli::command(); + let matches = command.get_matches(); + let cli = Cli::from_arg_matches(&matches)?; + let (_globals, _merged) = load_globals_and_merge_selected_subcommand( + &matches, + cli.command, + || Ok::<_, std::io::Error>(()), + )?; + Ok(()) +} +``` + +### Hello world walkthrough + + + +The `hello_world` example crate demonstrates these patterns in a compact +setting. Global options such as `--recipient` or `--salutation` are resolved by +`load_global_config`, which now reuses +`HelloWorldCli::compose_layers_from_iter` to collect defaults, discovered +files, and environment variables before applying CLI overrides. As an +intentional example-specific exception to the general append strategy, +`load_global_config` clears earlier salutation contributions when +`-s/--salutation` is explicitly supplied, so that CLI input replaces file or +environment values. The `greet` subcommand adds optional behaviour like a +preamble (`--preamble "Good morning"`) or custom punctuation while reusing the +merged global configuration. The `take-leave` subcommand combines switches and +optional arguments (`--wave`, `--gift`, `--channel email`, `--remind-in 15`) +alongside greeting adjustments (`--preamble "Until next time"`, +`--punctuation ?`) to describe how the farewell should unfold. Each subcommand +struct derives `OrthoConfig` so defaults from `[cmds.greet]` or +`[cmds.take-leave]` merge automatically when `load_and_merge_selected()` is +invoked on the derived `Commands` enum. + +Behavioural tests in `examples/hello_world/tests` exercise scenarios such as +`hello_world greet --preamble "Good morning"` and running +`hello_world --is-excited take-leave` with `--gift biscuits`, `--remind-in 15`, +`--channel email`, and `--wave`. These end-to-end checks verify that CLI +arguments override configuration files and that validation errors surface +cleanly when callers provide blank strings or conflicting switches. + +Sample configuration files live in `examples/hello_world/config`. The +`baseline.toml` defaults underpin both the automated tests and the demo +scripts, while `overrides.toml` extends the baseline to demonstrate inheritance +by adjusting the recipient and salutation. The paired `scripts/demo.sh` and +`scripts/demo.cmd` helpers copy these files into a temporary directory before +running `cargo run -p hello_world`, illustrating how file defaults, environment +variables, and CLI arguments override one another without mutating the working +tree. + +### Treating clap defaults as absent + +Non‑`Option` fields annotated with `#[arg(default_value_t = ...)]` normally +override configuration files and environment variables because `clap` always +populates them. The `cli_default_as_absent` attribute changes this behaviour: +when the user does not explicitly provide a value on the command line, the +field is excluded from the CLI layer so that file and environment values take +precedence. + +Add `cli_default_as_absent` and define the default in clap. The derive macro +now infers the struct default from clap's default metadata, so the default only +needs to be declared once: + +```rust +#[derive(Parser, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "APP_")] +struct GreetArgs { + #[arg(long, default_value_t = String::from("!"))] + #[ortho_config(cli_default_as_absent)] + punctuation: String, +} +``` + +`default_value_t` and `default_values_t` are supported for inferred defaults. +`default_value` inference is intentionally unsupported for now; use +`default_value_t` or add an explicit `#[ortho_config(default = ...)]` to avoid +string-parser mismatches. Parser-faithful `default_value` inference is planned +as a day-2 follow-up. + +If `#[ortho_config(default = ...)]` is still provided, that explicit value +remains available for generated defaults/documentation metadata. + +**Precedence with the attribute (lowest to highest):** + +1. Struct default (`#[ortho_config(default = ...)]` or inferred from clap) +2. Configuration file +3. Environment variable +4. Explicit CLI override (e.g. `--punctuation "?"`) + +Without `cli_default_as_absent`, the clap default would always beat the file +and environment layers. With the attribute, calling `greet` without +`--punctuation` allows a `[cmds.greet] punctuation = "?"` file entry or +`APP_CMDS_GREET_PUNCTUATION=?` environment variable to win. + +When using this attribute, pass the `ArgMatches` so the crate can inspect +`value_source()`: + +```rust +let matches = GreetArgs::command().get_matches(); +let cli = GreetArgs::from_arg_matches(&matches)?; +let merged = cli.load_and_merge_with_matches(&matches)?; +``` + +Clap's `value_source()` uses argument IDs (the field identifier unless +`#[arg(id = "...")]` overrides it). This behaviour requires the `serde_json` +feature (enabled by default). + +### Dispatching with `clap‑dispatch` + +The `clap‑dispatch` crate can be combined with `OrthoConfig` to simplify +subcommand execution. Each subcommand struct implements a trait defining the +action to perform. An enum of subcommands is annotated with +`#[clap_dispatch(fn run(...))]`, and the `load_and_merge_subcommand_for` +function can be called on each variant before dispatching. See the +`Subcommand Configuration` section of the `OrthoConfig` [README](../README.md) +for a complete example. + +## Error handling + +`load` and `load_and_merge_subcommand_for` return `OrthoResult`, an alias for +`Result>`. `OrthoError` wraps errors from `clap`, file I/O +and `figment`. Failures during the final merge of CLI values over configuration +sources surface as the `Merge` variant, providing clearer diagnostics when the +combined data is invalid. When multiple sources fail, the errors are collected +into the `Aggregate` variant so callers can inspect each individual failure. +Consumers should handle these errors appropriately, for example by printing +them to stderr and exiting. If required fields are missing after merging, the +crate returns `OrthoError::MissingRequiredValues` with a user‑friendly list of +missing paths and hints on how to provide them. For example: + +```plaintext +Missing required values: + sample_value (use --sample-value, SAMPLE_VALUE, or file entry) +``` + +### Preserving `clap` display exits + +When a user passes `--help` or `--version`, `clap` surfaces specialized +`ErrorKind::DisplayHelp` / `DisplayVersion` errors so applications can print +usage text and exit successfully. Deriving `OrthoConfig` often goes hand in +hand with `Cli::try_parse()` so applications can map errors into their own +types. Before performing that conversion, call +`ortho_config::is_display_request` to detect these cases and delegate to +`err.exit()`: + +```rust +use clap::Parser; +use ortho_config::{is_display_request, OrthoConfig}; + +fn parse_cli() -> Result { + match MyCli::try_parse() { + Ok(cli) => Ok(cli), + Err(mut err) => { + if is_display_request(&err) { + err.exit(); + } + Err(CliError::ArgumentParsing(err.into())) + } + } +} +``` + +The `examples/hello_world` crate applies this pattern in `main.rs`. Behavioural +tests assert that both `--help` and `--version` exit with code 0 so regressions +are caught automatically. + +### Aggregating multiple errors + +To return multiple errors in one go, use `OrthoError::aggregate`. It accepts +any iterator of items that can be converted into `Arc` so both +owned and shared errors are supported. If the list might be empty, +`OrthoError::try_aggregate` returns `Option` instead of panicking: + +```rust +use std::sync::Arc; +use ortho_config::OrthoError; + +// From bare errors +let err = OrthoError::aggregate(vec![ + OrthoError::Validation { key: "port".into(), message: "must be positive".into() }, + OrthoError::gathering(figment::Error::from("invalid")), +]); + +// From shared errors +let err = OrthoError::aggregate(vec![ + Arc::new(OrthoError::Validation { key: "x".into(), message: "bad".into() }), + OrthoError::gathering_arc(figment::Error::from("boom")), +]); +``` + +### Gathering vs Merge errors + +`OrthoConfig` distinguishes between two phases of configuration loading: + +- **Gathering** (`OrthoError::Gathering`): Errors that occur while reading + configuration sources (files, environment variables). These indicate problems + with the source data itself, such as malformed TOML or invalid JSON. + +- **Merge** (`OrthoError::Merge`): Errors that occur while combining layers and + deserializing the final configuration. These indicate incompatibilities + between the merged data and the target struct, such as type mismatches or + invalid field values. + +When deserializing the final merged configuration fails (for example, because a +field has an invalid type after all layers are combined), the error is reported +as `Merge`. This distinction helps diagnose whether an issue lies with a +specific source file (Gathering) or with the combined result of all layers +(Merge). + +### Mapping errors ergonomically + +To reduce boiler‑plate when converting between error types, the crate exposes +small extension traits: + +- `OrthoResultExt::into_ortho()` converts `Result` into + `OrthoResult` when `E: Into` (e.g., `serde_json::Error`). +- `OrthoMergeExt::into_ortho_merge()` converts `Result` + into `OrthoResult` as `OrthoError::Merge`. +- `OrthoJsonMergeExt::into_ortho_merge_json()` converts + `Result` into `OrthoResult` as `OrthoError::Merge`, + preserving location information from the JSON parser. +- `IntoFigmentError::into_figment()` converts `Arc` (or + `&Arc`) into `figment::Error` for interop in tests or adapters, + cloning the inner error to preserve structured details where possible. +- `ResultIntoFigment::to_figment()` converts `OrthoResult` into + `Result`. + +Examples: + +```rust +use ortho_config::{OrthoMergeExt, OrthoResultExt, ResultIntoFigment}; + +fn sanitize(v: &T) -> ortho_config::OrthoResult { + serde_json::to_value(v).into_ortho() +} + +fn extract(fig: figment::Figment) -> ortho_config::OrthoResult { + fig.extract::().into_ortho_merge() +} + +fn interop(r: ortho_config::OrthoResult) -> Result { + r.to_figment() +} +``` + +## Documentation metadata (OrthoConfigDocs) + +The derive macro now emits an `OrthoConfigDocs` implementation alongside the +runtime loader. This lets tooling such as `cargo-orthohelp` serialize a stable, +clap-agnostic intermediate representation (IR) for man pages and PowerShell +help. + +```rust +use ortho_config::docs::OrthoConfigDocs; + +#[derive(serde::Deserialize, serde::Serialize, ortho_config::OrthoConfig)] +#[ortho_config(prefix = "APP")] +struct AppConfig { + #[ortho_config(default = 8080)] + port: u16, +} + +let ir = AppConfig::get_doc_metadata(); +let json = ortho_config::serde_json::to_string_pretty(&ir)?; +println!("{json}"); +``` + +When IDs are not supplied, the macro generates deterministic defaults such as +`{app}.about` for the CLI overview and `{app}.fields.{field}.help` for field +descriptions. Field-level metadata can be refined with `help_id`, +`long_help_id`, `value(type = "...")`, `deprecated(note_id = "...")`, +`env(name = "...")`, and `file(key_path = "...")`. These documentation +attributes affect only the emitted IR; they do not change runtime naming or +loading behaviour. + +### Generating IR with cargo-orthohelp + +`cargo-orthohelp` compiles a tiny bridge binary that calls +`OrthoConfigDocs::get_doc_metadata()`, resolves Fluent messages per locale, and +writes localized IR JSON into the chosen output directory. Add metadata to the +package `Cargo.toml` so the tool knows which config type to load: + +```toml +[package.metadata.ortho_config] +root_type = "hello_world::cli::HelloWorldCli" +locales = ["en-US", "ja"] +``` + +Run the tool from the project root: + +```bash +cargo orthohelp --out-dir target/orthohelp --locale en-US +``` + +`--cache` reuses any previously generated IR cached under +`target/orthohelp//ir.json`, while `--no-build` skips the bridge build +and fails if the cache is missing. The generated per-locale JSON lives under +`/ir/.json` and is ready for downstream generators. + +### Generating man pages + +`cargo-orthohelp` can generate roff-formatted man pages from the localized IR. +Use `--format man` to produce `man/man/.` files suitable for +installation via `make install` or packaging: + +```bash +cargo orthohelp --format man --out-dir target/man --locale en-US +``` + +The generator produces standard man page sections in the canonical order: + +1. **NAME** – binary name and one-line description +2. **SYNOPSIS** – usage pattern with flags +3. **DESCRIPTION** – expanded about text +4. **OPTIONS** – CLI flags with types, defaults, and possible values +5. **ENVIRONMENT** – environment variables mapped to fields +6. **FILES** – configuration file paths and discovery locations +7. **PRECEDENCE** – source priority order (defaults → file → env → CLI) +8. **EXAMPLES** – usage examples from the IR +9. **SEE ALSO** – related commands and documentation links +10. **EXIT STATUS** – standard exit codes + +Additional options: + +- `--man-section ` – man page section number (default: 1) +- `--man-date ` – override the date shown in the footer +- `--man-split-subcommands` – generate separate man pages for each subcommand + +Text is automatically escaped for roff: backslashes are doubled, and leading +dashes, periods, and single quotes are escaped to prevent macro interpretation. +Enum fields list their possible values in the OPTIONS description. + +### Generating PowerShell help + +`cargo-orthohelp` can generate PowerShell external help in Microsoft Assistance +Markup Language (MAML) alongside a wrapper module so `Get-Help {BinName} -Full` +surfaces the same configuration metadata as the man page generator. Use the +`ps` format to emit the module layout under `powershell/`: + +```bash +cargo orthohelp --format ps --out-dir target/orthohelp --locale en-US +``` + +The generator produces: + +- `powershell//.psm1` – wrapper module. +- `powershell//.psd1` – module manifest. +- `powershell///-help.xml` – MAML help. +- `powershell///about_.help.txt` – about topic. + +`en-US` help is always generated. If only other locales are rendered, the +generator copies the first locale into `en-US` unless fallback generation is +disabled with `--ensure-en-us false`. + +PowerShell options: + +- `--ps-module-name ` – override the module name (defaults to the binary + name). +- `--ps-split-subcommands ` – emit wrapper functions for subcommands. +- `--ps-include-common-parameters ` – include CommonParameters in MAML. +- `--ps-help-info-uri ` – set `HelpInfoUri` for Update-Help payloads. +- `--ensure-en-us ` – control the `en-US` fallback behaviour. + +To set defaults in `Cargo.toml`, use the Windows metadata table: + +```toml +[package.metadata.ortho_config.windows] +module_name = "MyModule" +include_common_parameters = true +split_subcommands_into_functions = false +help_info_uri = "https://example.com/help/MyModule" +``` + +## Additional notes + +- **Vector merging** – For `Vec` fields the default merge strategy is + `append`, meaning that values from the configuration file appear first, then + environment variables and finally CLI arguments. Use + `merge_strategy = "append"` explicitly for clarity. When overrides should + discard earlier layers entirely (for example, to replace a default list with + a CLI-provided value) apply `merge_strategy = "replace"` instead. +- **Map merging** – Map fields (such as `BTreeMap`) default to keyed + merges, where later layers update only the entries they define. Apply + `merge_strategy = "replace"` when later layers must replace the entire map. + The hello_world example exposes a `greeting_templates` map that uses this + strategy, so declarative configuration files can swap the full template set + at once. + +- **Option<T> fields** – Fields of type `Option` are not treated as + required. They default to `None` and can be set via any source. Required CLI + arguments can be represented as `Option` to allow configuration defaults + while still requiring the CLI to provide a value when defaults are absent; + see the `vk` example above. + +- **Changing naming conventions** – Runtime naming continues to use the + default snake/hyphenated (underscores → hyphens)/upper snake mappings. For + documentation output, use `env(name = "...")` and `file(key_path = "...")` to + override IR metadata without altering runtime behaviour. + +- **Testing** – Because the CLI and environment variables are merged at + runtime, integration tests should set environment variables and construct CLI + argument vectors to exercise the merge logic. The `figment` crate makes it + easy to inject additional providers when writing unit tests. + +- **Sanitized providers** – The `sanitized_provider` helper returns a `Figment` + provider with `None` fields removed. It aids manual layering when bypassing + the derive macro. For example: + + ```rust + use figment::{Figment, providers::Serialized}; + use ortho_config::sanitized_provider; + + let fig = Figment::from(Serialized::defaults(&Defaults::default())) + .merge(sanitized_provider(&cli)?); + let cfg: Defaults = fig.extract()?; + ``` + +## Conclusion + +`OrthoConfig` streamlines configuration management in Rust applications. By +defining a single struct and annotating it with a small number of attributes, +developers obtain a full configuration parser that respects CLI arguments, +environment variables and configuration files with predictable precedence. +Subcommand support and integration with `clap‑dispatch` further reduce +boiler‑plate in complex CLI tools. The example `vk` repository demonstrates how +a real application can adopt `OrthoConfig` to handle global options and +subcommand defaults. Contributions to the project are welcome, and the design +documents outline planned improvements such as richer error messages and +support for additional naming strategies. diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 12c51ff..1911d2a 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -24,9 +24,12 @@ compact and omits build output such as `target/`. ├── docs/ │ ├── adrs/ │ │ └── 0001-single-file-gnu-make-parse.md +│ ├── execplans/ +│ │ └── adr-0001-single-file-gnu-make-parse.md │ ├── contents.md │ ├── design.md │ ├── developers-guide.md +│ ├── ortho-config-users-guide.md │ ├── polonius.md │ ├── repository-layout.md │ ├── terms-of-reference.md @@ -67,6 +70,9 @@ compact and omits build output such as `target/`. - `docs/adrs/`: Holds sequential, stable records of architectural decisions. - `docs/adrs/0001-single-file-gnu-make-parse.md`: Records the proposed boundary for parsing one GNU Makefile into versioned JSON facts. +- `docs/execplans/`: Holds living, approval-gated implementation plans. +- `docs/execplans/adr-0001-single-file-gnu-make-parse.md`: Plans the staged, + test-first implementation of ADR-0001. - `docs/contents.md`: Indexes the documentation set and should be updated when documentation files are added, renamed, or removed. - `docs/design.md`: Defines the living technical design, including the command @@ -77,6 +83,8 @@ compact and omits build output such as `target/`. tooling used to work on the generated project. - `docs/polonius.md`: Records the Polonius compiler contract, borrow-centric design rules, and audited migration sites. +- `docs/ortho-config-users-guide.md`: Imports the command-line and configuration + library guidance used to plan the CLI adapter. - `docs/repository-layout.md`: Documents the repository tree and path responsibilities. - `docs/terms-of-reference.md`: Defines the problem space, stakeholders, scope, @@ -116,6 +124,8 @@ compact and omits build output such as `target/`. a documentation file is added, renamed, or removed. - Keep accepted and proposed architectural decisions under `docs/adrs/`; do not renumber a decision record after publication. +- Keep living implementation plans under `docs/execplans/` and preserve their + approval, progress, decision, and retrospective sections as work proceeds. - Keep build and validation entrypoints in `Makefile`; prefer adding or extending a Make target over documenting an ad hoc command. - Keep continuous integration workflow changes under `.github/workflows/` and diff --git a/typos.toml b/typos.toml index 33f2e3d..ffe8da3 100644 --- a/typos.toml +++ b/typos.toml @@ -1014,6 +1014,24 @@ extend-ignore-re = [ "internationalizers" = "internationalizers" "internationalizes" = "internationalizes" "internationalizing" = "internationalizing" +"italicisable" = "italicizable" +"italicisation" = "italicization" +"italicisations" = "italicizations" +"italicise" = "italicize" +"italicised" = "italicized" +"italiciser" = "italicizer" +"italicisers" = "italicizers" +"italicises" = "italicizes" +"italicising" = "italicizing" +"italicizable" = "italicizable" +"italicization" = "italicization" +"italicizations" = "italicizations" +"italicize" = "italicize" +"italicized" = "italicized" +"italicizer" = "italicizer" +"italicizers" = "italicizers" +"italicizes" = "italicizes" +"italicizing" = "italicizing" "itemisable" = "itemizable" "itemisation" = "itemization" "itemisations" = "itemizations" @@ -2440,6 +2458,24 @@ extend-ignore-re = [ "uncategorizers" = "uncategorizers" "uncategorizes" = "uncategorizes" "uncategorizing" = "uncategorizing" +"underutilisable" = "underutilizable" +"underutilisation" = "underutilization" +"underutilisations" = "underutilizations" +"underutilise" = "underutilize" +"underutilised" = "underutilized" +"underutiliser" = "underutilizer" +"underutilisers" = "underutilizers" +"underutilises" = "underutilizes" +"underutilising" = "underutilizing" +"underutilizable" = "underutilizable" +"underutilization" = "underutilization" +"underutilizations" = "underutilizations" +"underutilize" = "underutilize" +"underutilized" = "underutilized" +"underutilizer" = "underutilizer" +"underutilizers" = "underutilizers" +"underutilizes" = "underutilizes" +"underutilizing" = "underutilizing" "uninitialisable" = "uninitializable" "uninitialisation" = "uninitialization" "uninitialisations" = "uninitializations" From 7df6371b466546e2ab41ed28f6a832d9b2974c7a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 13 Jul 2026 22:14:41 +0100 Subject: [PATCH 02/29] Implement single-file GNU Make parsing Add the domain-owned report model, parser port, lossless parser adapter, explicit-only OrthoConfig CLI, capability-oriented input, and compact JSON reporting for ADR-0001. Validate source locations, parser recovery, schema compatibility, snapshots, CLI behaviour, inert hostile input, and output failures with unit, property, behavioural, and end-to-end tests. Keep the ExecPlan blocked on the confirmed upstream `!=` assignment defect. Record that limitation as an explicit regression test so this checkpoint is gate-clean without treating the scope decision as resolved. --- Cargo.lock | 3070 +++++++++++++++++ Cargo.toml | 27 + docs/adrs/0001-single-file-gnu-make-parse.md | 2 +- docs/design.md | 17 + docs/developers-guide.md | 24 +- .../adr-0001-single-file-gnu-make-parse.md | 38 +- docs/repository-layout.md | 37 +- docs/rstest-bdd-users-guide.md | 270 +- docs/users-guide.md | 93 +- schemas/makeutil.parse.v1.schema.json | 139 + src/adapters/cli.rs | 181 + src/adapters/makefile.rs | 229 ++ src/adapters/mod.rs | 7 + src/adapters/source.rs | 76 + src/application.rs | 299 ++ src/domain/location.rs | 138 + src/domain/mod.rs | 192 ++ src/lib.rs | 21 +- src/main.rs | 22 +- src/ports.rs | 126 + tests/cli_e2e.rs | 99 + tests/domain_contract.rs | 164 + tests/features/parse.feature | 21 + tests/fixtures/makefiles/all-facts.mk | 10 + tests/fixtures/makefiles/recovered.mk | 4 + tests/output_failures.rs | 32 + tests/parse_bdd.rs | 135 + tests/report_schema.rs | 58 + ...a__all_fact_variants_have_stable_json.snap | 161 + ...ema__recovered_output_has_stable_json.snap | 113 + tests/stub.rs | 13 - typos.toml | 1 + 32 files changed, 5578 insertions(+), 241 deletions(-) create mode 100644 schemas/makeutil.parse.v1.schema.json create mode 100644 src/adapters/cli.rs create mode 100644 src/adapters/makefile.rs create mode 100644 src/adapters/mod.rs create mode 100644 src/adapters/source.rs create mode 100644 src/application.rs create mode 100644 src/domain/location.rs create mode 100644 src/domain/mod.rs create mode 100644 src/ports.rs create mode 100644 tests/cli_e2e.rs create mode 100644 tests/domain_contract.rs create mode 100644 tests/features/parse.feature create mode 100644 tests/fixtures/makefiles/all-facts.mk create mode 100644 tests/fixtures/makefiles/recovered.mk create mode 100644 tests/output_failures.rs create mode 100644 tests/parse_bdd.rs create mode 100644 tests/report_schema.rs create mode 100644 tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap create mode 100644 tests/snapshots/report_schema__recovered_output_has_stable_json.snap delete mode 100644 tests/stub.rs diff --git a/Cargo.lock b/Cargo.lock index c045ab4..1a7d320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,3076 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras 0.18.4", + "io-lifetimes 2.0.4", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras 0.19.0", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "camino", + "cap-primitives 3.4.5", + "io-extras 0.18.4", + "io-lifetimes 2.0.4", + "rustix", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "camino", + "cap-primitives 4.0.2", + "io-extras 0.19.0", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-dispatch" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a558b9547b590c876e46e301da15d3b0e93b0384fd50d2c7870f7ea86760df5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.3", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gherkin" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" +dependencies = [ + "heck 0.4.1", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.118", + "textwrap", + "thiserror 1.0.69", + "typed-builder", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "googletest" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94" +dependencies = [ + "googletest_macro", + "num-traits", + "regex", + "rustversion", +] + +[[package]] +name = "googletest_macro" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "sys-locale", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes 2.0.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281c43ff06dcb331e9356d30e38853d559ce3d0a3f693e0b0e102667dec14fb1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0b351864e7ffbc5db9273daf7fa1b4d5177b0946713d667ca571b83c0b4045" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "makefile-lossless" +version = "0.3.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a892a73d2d24783ef4355b310e9d04e5a1c91aabca3c417d60ac524d64e4217a" +dependencies = [ + "log", + "rowan", +] + [[package]] name = "makeutil" version = "0.1.0" +dependencies = [ + "assert_cmd", + "camino", + "cap-std 4.0.2", + "clap", + "data-encoding", + "googletest", + "insta", + "jsonschema", + "makefile-lossless", + "ortho_config", + "pretty_assertions", + "proptest", + "rowan", + "rstest", + "rstest-bdd", + "rstest-bdd-macros", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "newt-hype" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8b7b69b0eafaa88ec8dc9fe7c3860af0a147517e5207cfbd0ecd21cd7cde18" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ortho_config" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9314c7a4be184287f3f9a66fcbcec054ac215d4975152df86b7aadeae8d5e019" +dependencies = [ + "camino", + "cap-std 3.4.5", + "clap", + "clap-dispatch", + "directories", + "dirs", + "dunce", + "figment", + "fluent-bundle", + "fluent-syntax", + "ortho_config_macros", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "tracing", + "uncased", + "unic-langid", + "xdg", +] + +[[package]] +name = "ortho_config_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3941ab7c592a0b93aadf12ce30e0ef3d01af87b46c8f363d8157dc33273ba83" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "version_check", + "yansi", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "referencing" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest-bdd" +version = "0.6.0-beta3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a53b8195de84b6694b210c967726e2a6e4bd82b3394951ad99b85062c7eca6b2" +dependencies = [ + "ctor", + "derive_more", + "fluent", + "gherkin", + "hashbrown 0.16.1", + "i18n-embed", + "inventory", + "log", + "regex", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "rust-embed", + "serde", + "serde_json", + "thiserror 2.0.18", + "unic-langid", +] + +[[package]] +name = "rstest-bdd-harness" +version = "0.6.0-beta3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2994169999c2037287129466b9c576cf51add440bb7d30ee16f712d4e2edf5" +dependencies = [ + "cargo_metadata", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "rstest-bdd-macros" +version = "0.6.0-beta3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83080cc7749d3c8b2040c0f8be7faf350817342daade1d56df744655e33e9640" +dependencies = [ + "camino", + "cap-std 3.4.5", + "cfg-if", + "convert_case 0.6.0", + "gherkin", + "newt-hype", + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "rstest-bdd-harness", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "syn 2.0.118", + "thiserror 2.0.18", + "walkdir", +] + +[[package]] +name = "rstest-bdd-patterns" +version = "0.6.0-beta3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9773a23087aa9d460f11a982232758e9438f9b09bf297eb1dac748b745a324bd" +dependencies = [ + "gherkin", + "regex", + "thiserror 2.0.18", +] + +[[package]] +name = "rstest-bdd-policy" +version = "0.6.0-beta3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3de569456285584981fdd241e1a87152e39fd2c3029ea1b88948b086bfbfbd6" + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.118", + "unicode-ident", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.118", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typed-builder" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe83c85a85875e8c4cb9ce4a890f05b23d38cd0d47647db7895d3d2a79566d2" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.118", + "unic-langid-impl", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 45c746a..9b77599 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,34 @@ readme = "README.md" keywords = ["make", "gnumake", "makefile", "cst", "roundtrip"] categories = ["development-tools"] +[features] +default = ["serde_json"] +serde_json = ["ortho_config/serde_json"] + [dependencies] +camino = "1.2.4" +cap-std = { version = "4.0.2", features = ["fs_utf8"] } +clap = { version = "4.5.54", features = ["derive"] } +data-encoding = "2.10.0" +makefile-lossless = "=0.3.40" +ortho_config = { version = "0.8.0", features = ["serde_json"] } +rowan = "0.16.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha2 = "0.11.0" +thiserror = "2.0.18" + +[dev-dependencies] +assert_cmd = "2.2.2" +googletest = "0.14.3" +insta = { version = "1.48.0", features = ["json"] } +jsonschema = { version = "0.47.0", default-features = false } +pretty_assertions = "1.4.1" +proptest = "1.11.0" +rstest = "0.26.1" +rstest-bdd = "0.6.0-beta3" +rstest-bdd-macros = "0.6.0-beta3" +tempfile = "3.27.0" # The package.metadata.binstall pkg-url intentionally mixes Jinja2 values # rendered at project generation with single-brace cargo-binstall variables diff --git a/docs/adrs/0001-single-file-gnu-make-parse.md b/docs/adrs/0001-single-file-gnu-make-parse.md index 146b9c3..cc0d757 100644 --- a/docs/adrs/0001-single-file-gnu-make-parse.md +++ b/docs/adrs/0001-single-file-gnu-make-parse.md @@ -2,7 +2,7 @@ ## Status -Proposed +Accepted on 2026-07-13 ## Context diff --git a/docs/design.md b/docs/design.md index c9c4472..4da6931 100644 --- a/docs/design.md +++ b/docs/design.md @@ -327,6 +327,23 @@ function marker. `makeutil` reports includes but never opens them. ## 7. Internal architecture +The first slice is implemented by `domain`, `ports`, `application`, and +`adapters` modules in one crate. This is a boundary protection measure, not a +pattern transplant: `MakefileParser` is the sole port because the upstream CST +is the sole volatile external semantic boundary. Source input, JSON, and CLI +code remain ordinary edge adapters. + +The parser port is owned by the domain and called only by `parse_source`. +Adapter implementations may compose upstream accessors and Rowan ranges, but +must return only `SyntaxObservation` values. Those observations are not a +second public schema and must not be consumed directly by the CLI. New callers +compose through `parse_source`, which owns validation, hashing, locations, +ordinals, and status. + +The direct `rowan` dependency exists only to bring its `AstNode` trait into the +parser adapter for upstream syntax ranges. `data-encoding` owns lower-case +digest rendering. Neither dependency expands the stable public contract. + | Component | Responsibility | | -------------- | ---------------------------------------------------------------------------------------------- | | CLI front end | Parse the command and validate that exactly one source was supplied. | diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f1bdef2..2eaf8d5 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1,6 +1,28 @@ # Developer Guide -This guide explains the contributor workflow for the generated makeutil project. +This guide explains the contributor workflow and internal conventions for +makeutil. + +The normative architecture is in [the design](design.md), with the accepted +slice boundary in [ADR-0001](adrs/0001-single-file-gnu-make-parse.md) and paths +described by [the repository layout](repository-layout.md). + +## Parser boundary + +`MakefileParser` is the only parser port. Its implementation returns ordered, +makeutil-owned `SyntaxObservation` values; upstream CST nodes and errors must +not cross the adapter boundary. `parse_source` owns UTF-8 validation, hashing, +locations, global ordinals, and complete-versus-recovered classification. + +New syntax collection belongs in the existing parser adapter unless a distinct +external capability requires another port. CLI path and stdin filename values +must continue to use OrthoConfig's explicit `ArgMatches` extraction, without +file or environment layers. + +Tests keep raw Makefile text under `tests/fixtures/makefiles/`. Unit and property +tests exercise the domain, `rstest-bdd` scenarios exercise observable +behaviour, black-box tests spawn the binary, and `insta` plus the JSON Schema +freeze the integration contract. ## Local Workflow diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 1e4e7ca..0c14860 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -4,13 +4,13 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` must be kept up to date as work proceeds. -Status: DRAFT — implementation requires explicit user approval. +Status: BLOCKED AT UPSTREAM CONTRACT GATE. ## Approval -- Approver: Pending. -- Approval date: Pending. -- Exact `makefile-lossless = "=0.3.40"` exception: Pending explicit approval. +- Approver: User. +- Approval date: 2026-07-13. +- Exact `makefile-lossless = "=0.3.40"` exception: Approved. When approval is granted, record the approver, date, exact-pin decision, and change `Status` to `APPROVED` before beginning Milestone 1. Silence or approval @@ -163,14 +163,14 @@ stop and resolve the conflict before editing `Cargo.toml`. resolved every actionable concern from three CodeRabbit review rounds. - [ ] Obtain a clean CodeRabbit follow-up after the service rate limit resets; the post-fix attempt stopped before analysis and emitted no new findings. -- [ ] Obtain explicit approval of this ExecPlan, including the exact parser pin - exception and the schema/path decisions. -- [ ] Milestone 1: prove upstream contracts and freeze makeutil-owned domain and - schema-v1 boundaries with red tests. -- [ ] Milestone 2: implement parser traversal, locations, and recovered output - against the fixture corpus. -- [ ] Milestone 3: implement OrthoConfig CLI, source, JSON, and process adapters - with behavioural and end-to-end validation. +- [x] (2026-07-13) Obtained explicit approval of this ExecPlan, including the + exact parser pin exception and schema/path decisions. +- [x] (2026-07-13) Milestone 1: proved upstream contracts and froze the + makeutil-owned domain and schema-v1 boundaries with red tests. +- [x] (2026-07-13) Milestone 2: implemented parser traversal, locations, and + recovered output against the fixture corpus. +- [x] (2026-07-13) Milestone 3: implemented OrthoConfig CLI, source, JSON, and + process adapters with behavioural and end-to-end validation. - [ ] Milestone 4: synchronize documentation, run full acceptance, and gather external consumer evidence. @@ -193,9 +193,23 @@ stop and resolve the conflict before editing `Cargo.toml`. `../../ortho-config/docs/users-guide.md`. Impact: it has been imported as `docs/ortho-config-users-guide.md` and is now a local implementation reference. +- Observation: `makefile-lossless` 0.3.40 documents `!=` as an assignment + operator, but parses valid GNU Make `A != printf seven` as recovered rule + fragments with diagnostics and exposes no `VariableDefinition`. Evidence: + the focused `assignment_operators_remain_source_faithful::case_7` test and a + live CLI reproduction both produce zero variable facts; the scrutineer + independently reproduced the failure. Impact: this triggers the approved + upstream stop condition. The exact pin cannot satisfy the source-faithful + variable contract without an upstream fix, a separately approved narrow + fallback parser, or an explicit scope reduction. ## Decision log +- Pending decision: resolve the `!=` parser gap before further implementation, + commits, or CodeRabbit review. The available choices are an upstream patch at + the exact pin, approval to change the dependency source/version, or an + explicit schema and behaviour limitation. Date/Author: 2026-07-13 / Codex. + - Decision: apply hexagonal architecture only at meaningful volatility and side-effect boundaries. Rationale: domain facts, locations, ordering, and parse outcome classification need pure tests; `makefile-lossless`, CLI diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 1911d2a..342da04 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -35,13 +35,20 @@ compact and omits build output such as `target/`. │ ├── terms-of-reference.md │ ├── users-guide.md │ └── ... +├── schemas/ +│ └── makeutil.parse.v1.schema.json ├── src/ - +│ ├── adapters/ +│ ├── domain/ +│ ├── application.rs │ ├── lib.rs -│ └── main.rs - +│ ├── main.rs +│ └── ports.rs ├── tests/ -│ └── stub.rs +│ ├── features/ +│ ├── fixtures/ +│ ├── snapshots/ +│ └── *.rs ├── AGENTS.md ├── Cargo.toml ├── LICENSE @@ -77,8 +84,8 @@ compact and omits build output such as `target/`. documentation files are added, renamed, or removed. - `docs/design.md`: Defines the living technical design, including the command contract, data model, architecture, and verification strategy. -- `docs/users-guide.md`: Explains how to use the generated project and its - public build and test commands. +- `docs/users-guide.md`: Explains `makeutil parse`, its JSON report, and exit + contract. - `docs/developers-guide.md`: Explains the contributor workflow and local tooling used to work on the generated project. - `docs/polonius.md`: Records the Polonius compiler contract, borrow-centric @@ -90,15 +97,21 @@ compact and omits build output such as `target/`. - `docs/terms-of-reference.md`: Defines the problem space, stakeholders, scope, constraints, and success criteria that govern the design. -- `src/lib.rs`: Contains library support for application logic and doctested - examples. +- `schemas/`: Holds normative, versioned external JSON contracts. +- `src/adapters/`: Implements CLI, source, and GNU Make parser edges. +- `src/domain/`: Owns the stable report and source-location model. +- `src/application.rs`: Validates source and assembles reports through the + parser port. +- `src/ports.rs`: Owns the minimal parser boundary and syntax observations. +- `src/lib.rs`: Exposes library application logic and doctested examples. - `src/main.rs`: Contains the application entrypoint and top-level executable wiring. -- `tests/`: Holds integration and behavioural tests that exercise public - behaviour. -- `tests/stub.rs`: Keeps the generated test directory valid until real tests - replace it. +- `tests/`: Holds unit-style integration, behavioural, schema, snapshot, and + black-box process tests. +- `tests/features/`: Holds Gherkin acceptance specifications. +- `tests/fixtures/makefiles/`: Holds source-faithful parser inputs. +- `tests/snapshots/`: Holds reviewed JSON snapshots. - `AGENTS.md`: Provides repository-specific working instructions for agents and contributors. - `Cargo.toml`: Defines package metadata, dependencies, lint policy, and Cargo diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 6c59991..c32902a 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1029,8 +1029,7 @@ Interface (GPUI) integration for harness delegation and test attributes. Add it as a dev-dependency: ```toml -[dev-dependencies] -rstest-bdd-harness-gpui = "0.6.0-beta3" +[dev-dependencies] rstest-bdd-harness-gpui = "0.6.0-beta3" ``` A direct `rstest-bdd-harness` dependency is not required when using @@ -1047,14 +1046,14 @@ when `attributes = ...` is omitted: #### GPUI panic diagnostics carry scenario context When a step running under `GpuiHarness` panics, the harness prepends the -feature path, scenario name, and feature-file line number to the panic -message before re-raising it through `panic::resume_unwind`. The same fields -are emitted as a `tracing::error!` record (`harness_type`, `feature_path`, +feature path, scenario name, and feature-file line number to the panic message +before re-raising it through `panic::resume_unwind`. The same fields are +emitted as a `tracing::error!` record (`harness_type`, `feature_path`, `scenario_name`, `scenario_line`) and as a matching `stderr` line, so test -runners that do not collect `tracing` events still surface the scenario name -on failure. This makes a failing GPUI scenario identifiable from the -`cargo test` or `cargo nextest` output without cross-referencing libtest -function names against feature files. For a concrete regression example, see +runners that do not collect `tracing` events still surface the scenario name on +failure. This makes a failing GPUI scenario identifiable from the `cargo test` +or `cargo nextest` output without cross-referencing libtest function names +against feature files. For a concrete regression example, see `crates/rstest-bdd-harness-gpui/tests/scenario_name_in_logs.rs`. #### Stateful GPUI scenarios with durable handles @@ -1087,12 +1086,19 @@ function names against feature files. For a concrete regression example, see > > -> | Operation | Vendored gpui (regression suite + these snippets) | Published `gpui 0.2.2` (downstream adopters) | +> | Operation | Vendored gpui (regression suite + these snippets) | Published +> `gpui 0.2.2` (downstream adopters) | > | --- | --- | --- | -> | `add_window_view` closure | `\|_context\| View::default()` (one argument) | `\|_window, view_cx\| View::new(view_cx)` (two arguments) | -> | obtain window handle | `visual_cx.window_handle()` (inherent method on `VisualTestContext`) | `vcx.window_handle()` (same call, but `window_handle` is a `VisualContext` trait method, so add `use gpui::VisualContext;`) | -> | `VisualTestContext::from_window` | returns `Option` (`let … else { panic!(…) }`) | returns `VisualTestContext` by value (no `Option`) | -> | `read_entity` / `update_entity` | `Option`/`Result` wrappers (`Some(1)`, `Ok(())`) | identity `type Result = T`; returns `R` directly | +> | `add_window_view` closure | `\|_context\| View::default()` (one argument) | +> `\|_window, view_cx\| View::new(view_cx)` (two arguments) | +> | obtain window handle | `visual_cx.window_handle()` (inherent method on +> `VisualTestContext`) | `vcx.window_handle()` (same call, but `window_handle` +> is a `VisualContext` trait method, so add `use gpui::VisualContext;`) | +> | `VisualTestContext::from_window` | returns `Option` +> (`let … else { panic!(…) }`) | returns `VisualTestContext` by value (no +> `Option`) | +> | `read_entity` / `update_entity` | `Option`/`Result` wrappers (`Some(1)`, +> `Ok(())`) | identity `type Result = T`; returns `R` directly | > > @@ -1114,40 +1120,39 @@ function names against feature files. For a concrete regression example, see ##### When to reach for the stateful playbook Stateful GPUI scenarios are those whose steps share durable resources, such as -a typed view entity and the window that owns it, and need mutable access to -the harness-provided `gpui::TestAppContext` as well. Scenarios that only read -the harness context, or that share state through ordinary +a typed view entity and the window that owns it, and need mutable access to the +harness-provided `gpui::TestAppContext` as well. Scenarios that only read the +harness context, or that share state through ordinary [`rstest`](https://docs.rs/rstest/) fixtures without also borrowing `TestAppContext` mutably, should keep using plain fixtures and skip this -playbook. The pattern below is needed precisely when a single step must -borrow both `&mut TestAppContext` and shared mutable scenario state, which -the v0.6 `StepContext` API cannot express in one borrow. +playbook. The pattern below is needed precisely when a single step must borrow +both `&mut TestAppContext` and shared mutable scenario state, which the v0.6 +`StepContext` API cannot express in one borrow. ##### Durable handles versus visual context `gpui::TestAppContext::add_window_view` creates a test window and returns -`(Entity, VisualTestContext)`. `Entity` is the typed, durable handle -to the stored view; `VisualTestContext::window_handle()` returns the -`AnyWindowHandle` that identifies the window itself. Both are cheap to copy -and remain valid across steps. `VisualTestContext`, by contrast, borrows -from the `TestAppContext` it was created against and must not be stored -across steps: a later step is handed a fresh `&mut TestAppContext` from the -harness, so any saved `VisualTestContext` would be tied to a stale borrow. -Stateful steps therefore store `Entity` and `AnyWindowHandle` only, and -rebuild a fresh `VisualTestContext` inside each step that needs visual -interaction using +`(Entity, VisualTestContext)`. `Entity` is the typed, durable handle to +the stored view; `VisualTestContext::window_handle()` returns the +`AnyWindowHandle` that identifies the window itself. Both are cheap to copy and +remain valid across steps. `VisualTestContext`, by contrast, borrows from the +`TestAppContext` it was created against and must not be stored across steps: a +later step is handed a fresh `&mut TestAppContext` from the harness, so any +saved `VisualTestContext` would be tied to a stale borrow. Stateful steps +therefore store `Entity` and `AnyWindowHandle` only, and rebuild a fresh +`VisualTestContext` inside each step that needs visual interaction using `gpui::VisualTestContext::from_window(window, &mut cx)`. ##### Reset protocol -Thread-local scenario state outlives any single scenario, so each scenario -must observe a two-sided reset protocol to prevent handle leakage across -serial scenarios on the same test thread: +Thread-local scenario state outlives any single scenario, so each scenario must +observe a two-sided reset protocol to prevent handle leakage across serial +scenarios on the same test thread: - **Reset before assignment.** The first `#[given]` that opens a window resets the thread-local state before storing fresh handles. This makes a - reused thread observe a clean slate even if the previous scenario aborted - in a way that bypassed unwinding. + reused thread observe a clean slate even if the previous scenario aborted in + a way that bypassed unwinding. - **Reset after teardown.** A `Drop`-based fixture guard runs at scenario exit. Threading the guard through a `#[fixture]` ensures the reset runs on every unwind path: success, assertion failure, and panic alike. @@ -1160,18 +1165,19 @@ suppressed `Drop`. The `Drop` reset covers the symmetric case where the constructed when teardown happens. Deleting either call is a correctness regression: the regression suite at `crates/rstest-bdd-harness-gpui/tests/stateful_window.rs` asserts -`stale_window_count == 0` after the constructor-side reset to make the -ordering observable, and the second scenario in -`tests/features/stateful_window.feature` ("Opening a second GPUI window -starts from reset state") fails if the `Drop` reset is removed. - -Each `#[scenario]` that participates in this protocol must carry -`#[serial]` from the [`serial_test`](https://docs.rs/serial_test/) crate. -GPUI scenarios share a process-wide `TestAppContext` slot, and the -thread-local reset protocol assumes sequential execution; running stateful -GPUI scenarios in parallel breaks both invariants. - -See [test-runner parallelism and scenario state](#test-runner-parallelism-and-scenario-state) +`stale_window_count == 0` after the constructor-side reset to make the ordering +observable, and the second scenario in `tests/features/stateful_window.feature` +("Opening a second GPUI window starts from reset state") fails if the `Drop` +reset is removed. + +Each `#[scenario]` that participates in this protocol must carry `#[serial]` +from the [`serial_test`](https://docs.rs/serial_test/) crate. GPUI scenarios +share a process-wide `TestAppContext` slot, and the thread-local reset protocol +assumes sequential execution; running stateful GPUI scenarios in parallel +breaks both invariants. + +See +[test-runner parallelism and scenario state](#test-runner-parallelism-and-scenario-state) for the full `#[serial]`, cargo-nextest, `#[file_serial]`, and nextest test-group matrix. @@ -1183,11 +1189,11 @@ identifier. Treat that file as the executable reference: if a snippet here drifts from the suite, the suite wins and this section should be updated to match. -The first snippet declares the scenario-state container, the two reset -helpers, the `Drop`-based cleanup type, and the two `#[scenario]` functions -that bind to the feature file. Each scenario carries `#[serial]` and pulls -in the `scenario_state_cleanup` fixture so its constructor-side reset runs -before any step: +The first snippet declares the scenario-state container, the two reset helpers, +the `Drop`-based cleanup type, and the two `#[scenario]` functions that bind to +the feature file. Each scenario carries `#[serial]` and pulls in the +`scenario_state_cleanup` fixture so its constructor-side reset runs before any +step: ```rust,no_run # use rstest::fixture; @@ -1293,13 +1299,13 @@ fn fresh_gpui_window_is_opened( ``` The third snippet shows a `#[when]` and a `#[then]` step that rebuild -`VisualTestContext` from the stored window handle plus the -harness-provided `TestAppContext`. `VisualTestContext::from_window` -returns `Option` because the window handle and the -borrowed context must come from the same `TestAppContext`; the -`let … else { panic!(…) }` shape is appropriate here because a `None` value -means an invariant of the playbook has been violated, not a legitimate test -outcome. This form also passes the repository's pedantic lint profile: +`VisualTestContext` from the stored window handle plus the harness-provided +`TestAppContext`. `VisualTestContext::from_window` returns +`Option` because the window handle and the borrowed context +must come from the same `TestAppContext`; the `let … else { panic!(…) }` shape +is appropriate here because a `None` value means an invariant of the playbook +has been violated, not a legitimate test outcome. This form also passes the +repository's pedantic lint profile: ```rust,no_run # use rstest_bdd_macros::{then, when}; @@ -1337,8 +1343,8 @@ fn durable_handles_identify_the_updated_view( The error shape is consistent across all three snippets: surfaces of infrastructure invariants (handle reconstruction, fixture-stored handles) panic, and step-level domain assertions use `assert_eq!`. Steps that need to -distinguish a legitimate failure mode from a programming invariant should -return `StepResult<()>` and propagate the failure with `?`; mixing +distinguish a legitimate failure mode from a programming invariant should return +`StepResult<()>` and propagate the failure with `?`; mixing panic-on-invariant-violation `let … else { panic!(…) }` branches and `StepResult` within the same playbook reads ambiguously, so pick one shape per scenario. @@ -1347,13 +1353,13 @@ scenario. Steps request the GPUI context through the *reserved fixture key* `rstest_bdd_harness_context`. The key is part of the public contract: every -first-party adapter (Tokio, GPUI, and any future harness) injects its -typed context through the same key, so step authors can rely on it across -adapters. The *parameter name* used on the receiving side (`context` in the -snippets above and in the regression suite) is adapter-agnostic and chosen -by the step author for readability. The `#[from(rstest_bdd_harness_context)]` -attribute is what binds the key, so do not let parameter naming convince a -reader the binding name is part of the contract. +first-party adapter (Tokio, GPUI, and any future harness) injects its typed +context through the same key, so step authors can rely on it across adapters. +The *parameter name* used on the receiving side (`context` in the snippets +above and in the regression suite) is adapter-agnostic and chosen by the step +author for readability. The `#[from(rstest_bdd_harness_context)]` attribute is +what binds the key, so do not let parameter naming convince a reader the +binding name is part of the contract. #### Where to read more @@ -1363,14 +1369,13 @@ reader the binding name is part of the contract. - [rstest-bdd design][rstest-bdd-design] §2.7.6.5 records the v0.7.0 redesign target that retires the thread-local approach. - `crates/rstest-bdd-harness-gpui/tests/stateful_window.rs` is the - executable reference suite. Read it to confirm that the snippet here - still matches the regression coverage. + executable reference suite. Read it to confirm that the snippet here still + matches the regression coverage. - `crates/rstest-bdd-harness-gpui/tests/features/stateful_window.feature` shows the Gherkin shape the suite binds to. - The v0.6.0 migration guide's [Migrate a stateful GPUI - test][gpui-migration] subsection (inside "Adopt GPUI harness - configuration") walks readers through moving an existing scenario to the - playbook. + test][gpui-migration] subsection (inside "Adopt GPUI harness configuration") + walks readers through moving an existing scenario to the playbook. - Design-document §2.7.6.6 documents the feature-file rebuild-invalidation foot-gun (`.feature`-only edits do not trigger a rebuild until roadmap item 11.3.1 lands). @@ -1382,8 +1387,8 @@ reader the binding name is part of the contract. The snippets above are the lint-clean form used by the regression suite. The repository runs Whitaker's `no_unwrap_or_else_panic` Dylint lint from `make lint`, so `unwrap_or_else(|| panic!(…))` is rejected even when it encodes -an infrastructure invariant. The workspace also denies `clippy::expect_used` -and `clippy::unwrap_used`, so `.expect(...)` and `.unwrap()` are not acceptable +an infrastructure invariant. The workspace also denies `clippy::expect_used` and +`clippy::unwrap_used`, so `.expect(...)` and `.unwrap()` are not acceptable replacements. Use `let … else { panic!(…) }` with a fresh binding name: @@ -1396,21 +1401,20 @@ let Some(window) = current_handles() else { ``` Under `clippy::shadow_reuse`, avoid re-using the same name for a trimmed or -borrowed binding. For example, prefer a fresh guard name such as -`world_guard` over shadowing `world`. [ADR-013][adr-013] records the decision -to enforce this single Whitaker lint now while deferring the full Whitaker -suite. +borrowed binding. For example, prefer a fresh guard name such as `world_guard` +over shadowing `world`. [ADR-013][adr-013] records the decision to enforce this +single Whitaker lint now while deferring the full Whitaker suite. #### Bulk-migration cookbook When migrating a large test suite, factor the whole durable-handle **step library** — the `#[given]`/`#[when]`/`#[then]` steps together with the state -scaffolding — into one shared module per consuming crate, rather than copying it -into every test file. This is the v0.6.0 shape, and it is deliberately explicit. -Once roadmap items 11.1.3 and 11.1.4 ship (`ScenarioStore` and the -cleanup-guard fixture macro), the shared block shrinks to a single import and the -`#[scenario]` cleanup parameter is generated for you. Adopt the pattern now and -expect to shrink it then. +scaffolding — into one shared module per consuming crate, rather than copying +it into every test file. This is the v0.6.0 shape, and it is deliberately +explicit. Once roadmap items 11.1.3 and 11.1.4 ship (`ScenarioStore` and the +cleanup-guard fixture macro), the shared block shrinks to a single import and +the `#[scenario]` cleanup parameter is generated for you. Adopt the pattern now +and expect to shrink it then. ##### Why one shared module works @@ -1419,8 +1423,8 @@ Steps register globally at binary link time through the test binary is discoverable by every scenario in that binary, whatever module defined it. Placing the steps in a module that each binding file compiles in (through a `#[path]` include) therefore makes one library serve many scenarios -across many feature files. Each integration-test file is its own binary with its -own registry, so the same step text in two binaries never collides. +across many feature files. Each integration-test file is its own binary with +its own registry, so the same step text in two binaries never collides. Keep the shared module in the `tests/common/` **subdirectory** form (`tests/common/.rs`), not `tests/.rs`: Cargo compiles files placed @@ -1445,8 +1449,8 @@ tests/ ##### Binding a scenario Each binding file includes the shared library and binds a `#[scenario]` with no -steps of its own. Bind the shared fixture with a module-qualified `#[from(...)]` -path so its provenance stays visible at the binding site: +steps of its own. Bind the shared fixture with a module-qualified +`#[from(...)]` path so its provenance stays visible at the binding site: ```rust,no_run #[path = "common/ledger_steps.rs"] @@ -1480,16 +1484,16 @@ If a snippet here drifts from those, the suite wins. ##### Applying it to stateful GPUI scenarios -For GPUI, the shared module holds the durable-handle library from the -"Worked example" above: the `ScenarioState`, `thread_local!`, -the two reset helpers, the `ScenarioStateCleanup` `Drop` guard, the -`scenario_state_cleanup` fixture, and the `#[given]`/`#[when]`/`#[then]` steps -that store `Entity` and `AnyWindowHandle` and rebuild `VisualTestContext`. -Each binding then adds `harness = rstest_bdd_harness_gpui::GpuiHarness` and -`#[serial]` and binds the cleanup fixture the same module-qualified way, exactly -as the single-scenario worked example shows. The executable reference for the -GPUI half is `crates/rstest-bdd-harness-gpui/tests/stateful_window.rs`, so the -sharing mechanism (this suite) and the GPUI durable-handle specifics +For GPUI, the shared module holds the durable-handle library from the "Worked +example" above: the `ScenarioState`, `thread_local!`, the two reset helpers, the +`ScenarioStateCleanup` `Drop` guard, the `scenario_state_cleanup` fixture, and +the `#[given]`/`#[when]`/`#[then]` steps that store `Entity` and +`AnyWindowHandle` and rebuild `VisualTestContext`. Each binding then adds +`harness = rstest_bdd_harness_gpui::GpuiHarness` and `#[serial]` and binds the +cleanup fixture the same module-qualified way, exactly as the single-scenario +worked example shows. The executable reference for the GPUI half is +`crates/rstest-bdd-harness-gpui/tests/stateful_window.rs`, so the sharing +mechanism (this suite) and the GPUI durable-handle specifics (`stateful_window.rs`) are each backed by a runnable reference. Those GPUI snippets are written against the *vendored* gpui. Adopters on the @@ -1498,8 +1502,8 @@ using the vendored-to-published mapping table above (under "Durable handles versus visual context"). Editing only a `.feature` file does not trigger a rebuild (see design-document -§2.7.6.6), so touch a binding `.rs` file (or run `cargo clean -p `) after -changing feature text; otherwise a stale build can mask the change. +§2.7.6.6), so touch a binding `.rs` file (or run `cargo clean -p `) +after changing feature text; otherwise a stale build can mask the change. #### Test-runner parallelism and scenario state @@ -1509,10 +1513,10 @@ from the [`serial_test`](https://docs.rs/serial_test/) crate is still required for `cargo test` compatibility, even though cargo-nextest runs each test in a separate operating-system process. -| Runner | `#[serial]` effect | Cross-process exclusivity | -| --- | --- | --- | -| `cargo test` | In-process mutex; required | Not provided by `#[serial]` | -| nextest (process-per-test) | Redundant-but-harmless | `#[file_serial]` or test-group | +| Runner | `#[serial]` effect | Cross-process exclusivity | +| -------------------------- | -------------------------- | ------------------------------ | +| `cargo test` | In-process mutex; required | Not provided by `#[serial]` | +| nextest (process-per-test) | Redundant-but-harmless | `#[file_serial]` or test-group | *Table: `#[serial]` behaviour by test runner.* @@ -1527,16 +1531,14 @@ target uses), each test is run in its own process. The `#[serial]` mutex is not contended across process boundaries, so the annotation is redundant-but-harmless for nextest runs. Keep it for `cargo test`; do not remove it just because nextest already isolates per-process thread-local state. -The design rationale is recorded in -[design-document §2.7.6.7][design-runner-parallelism], and the maintainer -convention is summarized in -[the developer guide][developer-serial-nextest]. +The design rationale is recorded in [design-document §2.7.6.7][ +design-runner-parallelism], and the maintainer convention is summarized in [the +developer guide][developer-serial-nextest]. When separate test processes or separate test binaries must not overlap, use a -cross-process mechanism instead of `#[serial]`. cargo-nextest -[test-groups][nextest-test-groups] define logical mutexes across the whole -nextest run. This example makes any test whose name contains `stateful_gpui::` -run one at a time: +cross-process mechanism instead of `#[serial]`. cargo-nextest [test-groups][ +nextest-test-groups] define logical mutexes across the whole nextest run. This +example makes any test whose name contains `stateful_gpui::` run one at a time: ```toml [test-groups] @@ -1645,9 +1647,8 @@ Tests that exercise skip-heavy flows no longer need to match on enums to verify that a step or scenario stopped executing. Use `rstest_bdd::assert_step_skipped!` to unwrap a `StepExecution::Skipped` outcome, optionally constraining its message, and -`rstest_bdd::assert_scenario_skipped!` to inspect -[`ScenarioStatus`][scenario-status] records. Both macros accept -`message_absent = true` to assert +`rstest_bdd::assert_scenario_skipped!` to inspect [`ScenarioStatus`][ +scenario-status] records. Both macros accept `message_absent = true` to assert that no message was provided and substring matching to confirm that a message contains the expected reason. @@ -2114,25 +2115,24 @@ Best practices for writing effective scenarios include: (for example, `1e3`, `-1E-9`), and the special values `NaN`, `inf`, and `Infinity` (matched case-insensitively). Matching is anchored: the entire step text must match the pattern; partial matches do not succeed. Escape - literal braces with `{{` and `}}`. Use - `\` to match a single backslash. A trailing `\` or any other backslash escape - is treated literally, so `\d` matches the two-character sequence `\d`. Nested - braces inside placeholders are not supported. Braces are not allowed inside - type hints. Placeholders use `{name}` or `{name:type}`; the type hint must - not contain braces (for example, `{n:{u32}}` and `{n:Vec<{u32}>}` are - rejected). To describe braces in the surrounding step text (for example, - referring to `{u32}`), escape them as `{{` and `}}` rather than placing them - inside `{name:type}`. The lexer closes the placeholder at the first `}` after - the optional type hint; any characters between the `:type` and that first `}` - are ignored (for example, `{n:u32 extra}` parses as `name = n`, `type = u32`). - `name` must start with a letter or underscore and may contain letters, - digits, or underscores (`[A-Za-z_][A-Za-z0-9_]*`). Whitespace within the type - hint is ignored (for example, `{count: u32}` and `{count:u32}` are both - accepted), but whitespace is not allowed between the name and the colon. - Prefer the compact form `{count:u32}` in new code. When a pattern contains no - placeholders, the step text must match exactly. Unknown type hints are - treated as generic placeholders and capture any non-newline text using a - non-greedy match. + literal braces with `{{` and `}}`. Use `\` to match a single backslash. A + trailing `\` or any other backslash escape is treated literally, so `\d` + matches the two-character sequence `\d`. Nested braces inside placeholders + are not supported. Braces are not allowed inside type hints. Placeholders use + `{name}` or `{name:type}`; the type hint must not contain braces (for example, + `{n:{u32}}` and `{n:Vec<{u32}>}` are rejected). To describe braces in the + surrounding step text (for example, referring to `{u32}`), escape them as + `{{` and `}}` rather than placing them inside `{name:type}`. The lexer closes + the placeholder at the first `}` after the optional type hint; any characters + between the `:type` and that first `}` are ignored (for example, + `{n:u32 extra}` parses as `name = n`, `type = u32`). `name` must start with a + letter or underscore and may contain letters, digits, or underscores + (`[A-Za-z_][A-Za-z0-9_]*`). Whitespace within the type hint is ignored (for + example, `{count: u32}` and `{count:u32}` are both accepted), but whitespace + is not allowed between the name and the colon. Prefer the compact form + `{count:u32}` in new code. When a pattern contains no placeholders, the step + text must match exactly. Unknown type hints are treated as generic + placeholders and capture any non-newline text using a non-greedy match. ## Data tables and doc strings diff --git a/docs/users-guide.md b/docs/users-guide.md index 10afe71..e285857 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -1,45 +1,48 @@ -# User Guide - -This guide explains how to use the generated makeutil project after rendering -it from the template. - -## Generated Tooling - -Generated projects use Rust 2024, a pinned nightly toolchain, strict lint -settings, and documented starter code. Library projects render `src/lib.rs`. -Application projects render `src/main.rs`, `src/lib.rs`, release automation, and -`[package.metadata.binstall]` metadata for binary installation. - -The pinned nightly runs the Polonius borrow-checking analysis through the -checked-in Cargo configuration. Use plain `cargo` or the documented Makefile -targets from the repository so the pinned compiler and required flags are -selected together. - -Development builds use Cranelift for debug code generation. On Linux targets, -`.cargo/config.toml` configures clang to link with `mold` so local debug builds -link quickly. Coverage generation uses `lld` instead because LLVM coverage -tools expect LLVM-compatible linker behaviour. - -## Makefile Targets - -The generated `Makefile` exposes these public targets: - -- `make all` runs formatting checks, linting, tests, and spelling checks. -- `make check-fmt` verifies Rust formatting. -- `make lint` runs rustdoc, Clippy, and Whitaker with warnings denied. -- `make test` runs `cargo nextest run` when cargo-nextest is installed and - falls back to `cargo test` otherwise. All projects also run doctests. -- `make build` builds the debug target. -- `make release` builds the release target. -- `make coverage` writes `lcov.info` using `cargo llvm-cov` and `lld`. -- `make audit` derives the Rust workspace root with `cargo metadata` and runs - `cargo audit` once from that root. -- `make markdownlint` checks Markdown files and enforces en-GB-oxendict - spelling through the pinned `typos` release. -- `make spelling` refreshes the shared Oxford dictionary when its published - source is newer than the ignored local cache, generates `typos.toml`, and - checks Markdown prose. -- `make nixie` validates Mermaid diagrams. - -Install `clang`, `lld`, `mold`, `python3`, and `cargo-audit` before running the -full generated workflow locally on Linux. +# User guide + +This guide explains how to parse one GNU Makefile into source-faithful JSON +facts with `makeutil`. + +## Parse a file + +Pass exactly one UTF-8 path to the `parse` subcommand: + +```shell +makeutil parse Makefile +``` + +The command writes one compact JSON document followed by a newline. It reports +explicit rules, recipes, variable definitions, include directives, conditional +ancestry, source locations, and parser diagnostics. It does not evaluate Make +expressions, run recipes or shell functions, or open reported include paths. + +## Parse standard input + +Use `-` for standard input and supply the logical path recorded in the report: + +```shell +makeutil parse --stdin-filename Makefile - < Makefile +``` + +`--stdin-filename` is required with `-` and rejected for file paths. These +arguments are command-line-only: environment variables and configuration files +cannot supply them. + +## Interpret results + +The normative output contract is +[`schemas/makeutil.parse.v1.schema.json`](../schemas/makeutil.parse.v1.schema.json). +Byte ranges are zero-based and end-exclusive. Display lines and byte columns are +one-based. + +| Exit code | Meaning | +| --- | --- | +| `0` | Parsing completed and JSON was emitted. | +| `1` | Parsing recovered partial facts with diagnostics and JSON was emitted. | +| `2` | Invocation, input, UTF-8, internal, serialization, or output failed. | + +_Table 1: `makeutil parse` exit codes._ + +Fatal failures write a stable `makeutil: OPERATION: DETAIL` diagnostic to +standard error and do not intentionally emit JSON. Recovered reports are +insufficient proof that a Makefile is compliant. diff --git a/schemas/makeutil.parse.v1.schema.json b/schemas/makeutil.parse.v1.schema.json new file mode 100644 index 0000000..9bbbe90 --- /dev/null +++ b/schemas/makeutil.parse.v1.schema.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/leynos/makeutil/schemas/makeutil.parse.v1.schema.json", + "title": "makeutil parse report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "tool", "source", "parse", "rules", "variables", "includes"], + "properties": { + "schema_version": { "const": 1 }, + "tool": { "$ref": "#/$defs/tool" }, + "source": { "$ref": "#/$defs/source" }, + "parse": { "$ref": "#/$defs/parse" }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } }, + "variables": { "type": "array", "items": { "$ref": "#/$defs/variable" } }, + "includes": { "type": "array", "items": { "$ref": "#/$defs/include" } } + }, + "$defs": { + "tool": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "parser", "parser_version"], + "properties": { + "name": { "const": "makeutil" }, + "version": { "type": "string", "minLength": 1 }, + "parser": { "const": "makefile-lossless" }, + "parser_version": { "const": "0.3.40" } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length"], + "properties": { + "path": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "byte_length": { "type": "integer", "minimum": 0 } + } + }, + "parse": { + "type": "object", + "additionalProperties": false, + "required": ["status", "diagnostics"], + "properties": { + "status": { "enum": ["complete", "recovered"] }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } } + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["start_byte", "end_byte", "start_line", "start_column", "end_line", "end_column"], + "properties": { + "start_byte": { "type": "integer", "minimum": 0 }, + "end_byte": { "type": "integer", "minimum": 0 }, + "start_line": { "type": "integer", "minimum": 1 }, + "start_column": { "type": "integer", "minimum": 1 }, + "end_line": { "type": "integer", "minimum": 1 }, + "end_column": { "type": "integer", "minimum": 1 } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["message", "code", "location"], + "properties": { + "message": { "type": "string" }, + "code": { "type": ["string", "null"] }, + "location": { "$ref": "#/$defs/location" } + } + }, + "condition": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "expression", "branch", "location"], + "properties": { + "kind": { "enum": ["ifdef", "ifndef", "ifeq", "ifneq"] }, + "expression": { "type": "string" }, + "branch": { "enum": ["if", "else"] }, + "location": { "$ref": "#/$defs/location" } + } + }, + "recipe": { + "type": "object", + "additionalProperties": false, + "required": ["ordinal", "text", "silent", "ignore_errors", "always_execute", "location"], + "properties": { + "ordinal": { "type": "integer", "minimum": 0 }, + "text": { "type": "string" }, + "silent": { "type": "boolean" }, + "ignore_errors": { "type": "boolean" }, + "always_execute": { "type": "boolean" }, + "location": { "$ref": "#/$defs/location" } + } + }, + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["ordinal", "targets", "prerequisites", "double_colon", "conditions", "recipes", "location"], + "properties": { + "ordinal": { "type": "integer", "minimum": 0 }, + "targets": { "type": "array", "items": { "type": "string" } }, + "prerequisites": { "type": "array", "items": { "type": "string" } }, + "double_colon": { "type": "boolean" }, + "conditions": { "type": "array", "items": { "$ref": "#/$defs/condition" } }, + "recipes": { "type": "array", "items": { "$ref": "#/$defs/recipe" } }, + "location": { "$ref": "#/$defs/location" } + } + }, + "variable": { + "type": "object", + "additionalProperties": false, + "required": ["ordinal", "name", "operator", "raw_value", "exported", "overridden", "define_block", "conditions", "location"], + "properties": { + "ordinal": { "type": "integer", "minimum": 0 }, + "name": { "type": "string" }, + "operator": { "enum": ["", "=", ":=", "::=", ":::=", "+=", "?=", "!="] }, + "raw_value": { "type": "string" }, + "exported": { "type": "boolean" }, + "overridden": { "type": "boolean" }, + "define_block": { "type": "boolean" }, + "conditions": { "type": "array", "items": { "$ref": "#/$defs/condition" } }, + "location": { "$ref": "#/$defs/location" } + } + }, + "include": { + "type": "object", + "additionalProperties": false, + "required": ["ordinal", "raw_path", "optional", "dynamic", "conditions", "location"], + "properties": { + "ordinal": { "type": "integer", "minimum": 0 }, + "raw_path": { "type": "string" }, + "optional": { "type": "boolean" }, + "dynamic": { "type": "boolean" }, + "conditions": { "type": "array", "items": { "$ref": "#/$defs/condition" } }, + "location": { "$ref": "#/$defs/location" } + } + } + } +} diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs new file mode 100644 index 0000000..9accdd9 --- /dev/null +++ b/src/adapters/cli.rs @@ -0,0 +1,181 @@ +//! OrthoConfig-backed command-line parsing and process-level exit policy. + +use std::ffi::OsString; + +use camino::Utf8Path; +use clap::{CommandFactory as _, FromArgMatches as _, Parser, Subcommand}; +use ortho_config::{CliValueExtractor as _, OrthoConfig}; +use serde::{Deserialize, Serialize}; + +use super::{ + MakefileLosslessParser, + source::{read_path, read_stdin}, +}; +use crate::{domain::ParseStatus, parse_source}; + +/// Root command line for `makeutil`. +#[derive(Debug, Parser)] +#[command(version, about)] +pub struct Cli { + /// Selected operation. + #[command(subcommand)] + pub command: Command, +} + +/// Available makeutil operations. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Parse one GNU Makefile into JSON facts. + Parse(ParseArgs), +} + +/// Explicit-only arguments for the parse subcommand. +#[derive(Debug, Clone, Default, Deserialize, Serialize, Parser, OrthoConfig)] +#[command(name = "parse")] +#[ortho_config(prefix = "MAKEUTIL_PARSE_")] +pub struct ParseArgs { + /// Logical source name required when reading `-` from standard input. + #[arg(long, requires = "path")] + #[ortho_config(cli_default_as_absent)] + pub stdin_filename: Option, + /// UTF-8 source path, or `-` for standard input. + pub path: String, +} + +/// Process outcome without terminating the embedding process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcessOutcome { + /// Conventional process exit code. + pub exit_code: u8, +} + +struct Streams<'stream> { + stdin: &'stream mut dyn std::io::Read, + stdout: &'stream mut dyn std::io::Write, + stderr: &'stream mut dyn std::io::Write, +} + +/// Parse arguments, run the command, and write only contract streams. +pub fn run_from( + command_line: I, + stdin: &mut impl std::io::Read, + stdout: &mut impl std::io::Write, + stderr: &mut impl std::io::Write, +) -> ProcessOutcome +where + I: IntoIterator, + T: Into + Clone, +{ + let mut streams = Streams { + stdin, + stdout, + stderr, + }; + let command = Cli::command(); + let matches = match command.try_get_matches_from(command_line) { + Ok(matches) => matches, + Err(error) => return render_clap_error(&error, &mut streams), + }; + let cli = match Cli::from_arg_matches(&matches) { + Ok(cli) => cli, + Err(error) => { + let _write_result = streams.stderr.write_all(error.to_string().as_bytes()); + return ProcessOutcome { exit_code: 2 }; + } + }; + match cli.command { + Command::Parse(parse_arguments) => run_parse(&parse_arguments, &matches, &mut streams), + } +} + +fn render_clap_error(error: &clap::Error, streams: &mut Streams<'_>) -> ProcessOutcome { + let exit_code = if error.use_stderr() { 2 } else { 0 }; + let writer = if error.use_stderr() { + &mut streams.stderr + } else { + &mut streams.stdout + }; + let _write_result = writer.write_all(error.to_string().as_bytes()); + ProcessOutcome { exit_code } +} + +fn run_parse( + parsed_arguments: &ParseArgs, + matches: &clap::ArgMatches, + streams: &mut Streams<'_>, +) -> ProcessOutcome { + let Some(parse_matches) = matches.subcommand_matches("parse") else { + return fatal( + streams.stderr, + "cli", + "parse subcommand matches were absent", + ); + }; + // This OrthoConfig extraction intentionally reads only explicit ArgMatches; + // path identity must never come from environment or configuration files. + let explicit = match parsed_arguments.extract_user_provided(parse_matches) { + Ok(value) => value, + Err(error) => return fatal(streams.stderr, "cli", &error.to_string()), + }; + let explicit_arguments: ParseArgs = match serde_json::from_value(explicit) { + Ok(explicit_arguments) => explicit_arguments, + Err(error) => return fatal(streams.stderr, "cli", &error.to_string()), + }; + let (bytes, logical_path) = match read_input(explicit_arguments, streams) { + Ok(input) => input, + Err(outcome) => return outcome, + }; + let report = match parse_source(&bytes, &logical_path, &MakefileLosslessParser) { + Ok(report) => report, + Err(crate::ParseApplicationError::InvalidUtf8(error)) => { + return fatal(streams.stderr, "source-utf8", &error.to_string()); + } + Err(error) => return fatal(streams.stderr, "parse-internal", &error.to_string()), + }; + let mut document = match serde_json::to_vec(&report) { + Ok(document) => document, + Err(error) => return fatal(streams.stderr, "json-serialize", &error.to_string()), + }; + document.push(b'\n'); + if let Err(error) = streams.stdout.write_all(&document) { + return fatal(streams.stderr, "stdout-write", &error.to_string()); + } + ProcessOutcome { + exit_code: u8::from(report.parse.status != ParseStatus::Complete), + } +} + +fn read_input( + arguments: ParseArgs, + streams: &mut Streams<'_>, +) -> Result<(Vec, String), ProcessOutcome> { + if arguments.path == "-" { + let logical_path = arguments.stdin_filename.ok_or_else(|| { + fatal( + streams.stderr, + "cli", + "--stdin-filename is required when PATH is -", + ) + })?; + return read_stdin(streams.stdin) + .map(|bytes| (bytes, logical_path)) + .map_err(|error| fatal(streams.stderr, "source-read", &error.to_string())); + } + if arguments.stdin_filename.is_some() { + return Err(fatal( + streams.stderr, + "cli", + "--stdin-filename is only valid when PATH is -", + )); + } + let path = Utf8Path::new(&arguments.path); + read_path(path) + .map(|bytes| (bytes, arguments.path)) + .map_err(|error| fatal(streams.stderr, error.operation(), &error.to_string())) +} + +fn fatal(stderr: &mut dyn std::io::Write, operation: &str, detail: &str) -> ProcessOutcome { + let message = format!("makeutil: {operation}: {detail}\n"); + let _write_result = stderr.write_all(message.as_bytes()); + ProcessOutcome { exit_code: 2 } +} diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs new file mode 100644 index 0000000..c4579bc --- /dev/null +++ b/src/adapters/makefile.rs @@ -0,0 +1,229 @@ +//! `makefile-lossless` 0.3.40 adapter for the domain-owned parser port. + +use makefile_lossless::{Conditional, Makefile, MakefileItem, Parse, SyntaxKind}; +use rowan::ast::AstNode as _; + +use crate::{ + domain::{ConditionBranch, SourceSpan}, + ports::{ + ConditionObservation, + MakefileParser, + ParserOutcome, + ParserPortError, + RecipeObservation, + SyntaxObservation, + }, +}; + +/// GNU Make parser backed by the exactly pinned lossless CST crate. +#[derive(Debug, Clone, Copy, Default)] +pub struct MakefileLosslessParser; + +impl MakefileParser for MakefileLosslessParser { + fn parse(&self, source: &str) -> Result { + let parsed = Parse::::parse_makefile(source); + let tree = parsed.tree(); + if tree.to_string() != source { + return Err(ParserPortError::RoundTripMismatch); + } + + let mut observations = Vec::new(); + collect_items(tree.items(), &[], source.len(), &mut observations)?; + collect_diagnostics(&parsed, source, &mut observations)?; + Ok(ParserOutcome { observations }) + } +} + +fn collect_items( + items: impl Iterator, + conditions: &[ConditionObservation], + source_length: usize, + observations: &mut Vec, +) -> Result<(), ParserPortError> { + for item in items { + match item { + MakefileItem::Rule(rule) => { + let recipes = rule + .recipe_nodes() + .map(|recipe| { + let text = recipe.text(); + Ok(RecipeObservation { + silent: recipe.is_silent(), + ignore_errors: recipe.is_ignore_errors(), + always_execute: text.trim_start_matches(['@', '-']).starts_with('+') + || text.starts_with('+'), + text, + span: span(recipe.text_range(), source_length)?, + }) + }) + .collect::, ParserPortError>>()?; + observations.push(SyntaxObservation::Rule { + targets: rule.targets().collect(), + prerequisites: rule.prerequisites().collect(), + double_colon: rule.is_double_colon(), + conditions: conditions.to_vec(), + recipes, + span: span(rule.syntax().text_range(), source_length)?, + }); + } + MakefileItem::Variable(variable) => { + observations.push(SyntaxObservation::Variable { + name: variable.name().ok_or(ParserPortError::MissingField { + field: "variable-name", + })?, + operator: variable.assignment_operator().unwrap_or_default(), + raw_value: variable.raw_value().unwrap_or_default().trim().to_owned(), + exported: variable.is_export(), + overridden: variable.is_override(), + define_block: variable.is_define(), + conditions: conditions.to_vec(), + span: span(variable.syntax().text_range(), source_length)?, + }); + } + MakefileItem::Include(include) => { + observations.push(SyntaxObservation::Include { + raw_path: include.path().ok_or(ParserPortError::MissingField { + field: "include-path", + })?, + optional: include.is_optional(), + conditions: conditions.to_vec(), + span: span(include.syntax().text_range(), source_length)?, + }); + } + MakefileItem::Conditional(conditional) => { + collect_conditional(&conditional, conditions, source_length, observations)?; + } + MakefileItem::Vpath(_) => {} + } + } + Ok(()) +} + +fn collect_conditional( + conditional: &Conditional, + outer: &[ConditionObservation], + source_length: usize, + observations: &mut Vec, +) -> Result<(), ParserPortError> { + let opening = conditional + .syntax() + .children() + .find(|node| node.kind() == SyntaxKind::CONDITIONAL_IF) + .ok_or(ParserPortError::MissingField { + field: "conditional-opening", + })?; + let kind = conditional + .conditional_type() + .ok_or(ParserPortError::MissingField { + field: "conditional-kind", + })?; + let expression = conditional.condition().unwrap_or_default(); + let mut if_conditions = outer.to_vec(); + if_conditions.push(ConditionObservation { + kind: kind.clone(), + expression: expression.clone(), + branch: ConditionBranch::If, + span: span(opening.text_range(), source_length)?, + }); + collect_items( + conditional.if_items(), + &if_conditions, + source_length, + observations, + )?; + + collect_else_branch( + conditional, + outer, + ElseBranch { + kind, + expression, + source_length, + }, + observations, + )?; + Ok(()) +} + +fn collect_else_branch( + conditional: &Conditional, + outer: &[ConditionObservation], + branch: ElseBranch, + observations: &mut Vec, +) -> Result<(), ParserPortError> { + if !conditional.has_else() { + return Ok(()); + } + let else_node = conditional + .syntax() + .children() + .find(|node| node.kind() == SyntaxKind::CONDITIONAL_ELSE) + .ok_or(ParserPortError::MissingField { + field: "conditional-else", + })?; + let mut else_conditions = outer.to_vec(); + else_conditions.push(ConditionObservation { + kind: branch.kind, + expression: branch.expression, + branch: ConditionBranch::Else, + span: span(else_node.text_range(), branch.source_length)?, + }); + collect_items( + conditional.else_items(), + &else_conditions, + branch.source_length, + observations, + ) +} + +struct ElseBranch { + kind: String, + expression: String, + source_length: usize, +} + +fn collect_diagnostics( + parsed: &Parse, + source: &str, + observations: &mut Vec, +) -> Result<(), ParserPortError> { + if !parsed.positioned_errors().is_empty() { + for error in parsed.positioned_errors() { + observations.push(SyntaxObservation::Diagnostic { + message: error.message.clone(), + code: error.code.clone(), + span: span(error.range, source.len())?, + }); + } + return Ok(()); + } + for error in parsed.errors() { + observations.push(SyntaxObservation::Diagnostic { + message: error.message.clone(), + code: None, + span: line_span(source, error.line), + }); + } + Ok(()) +} + +fn line_span(source: &str, one_based_line: usize) -> SourceSpan { + let target = one_based_line.saturating_sub(1); + let mut start = 0_usize; + let mut end = source.len(); + for (line, segment) in source.split_inclusive('\n').enumerate() { + if line == target { + end = start.saturating_add(segment.trim_end_matches(['\r', '\n']).len()); + break; + } + start = start.saturating_add(segment.len()); + } + SourceSpan { start, end } +} + +fn span( + range: makefile_lossless::TextRange, + source_length: usize, +) -> Result { + SourceSpan::new(range.start().into(), range.end().into(), source_length).map_err(Into::into) +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..414ed49 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,7 @@ +//! Edge adapters for GNU Make parsing, source input, JSON, and the CLI. + +pub mod cli; +mod makefile; +pub mod source; + +pub use makefile::MakefileLosslessParser; diff --git a/src/adapters/source.rs b/src/adapters/source.rs new file mode 100644 index 0000000..177f9fd --- /dev/null +++ b/src/adapters/source.rs @@ -0,0 +1,76 @@ +//! Capability-oriented file input and narrow standard-input support. + +use std::io::Read as _; + +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::File}; +use thiserror::Error; + +/// Source input failure classified for stable CLI diagnostics. +#[derive(Debug, Error)] +pub enum SourceReadError { + /// The path could not be opened. + #[error("could not open {path}: {source}")] + Open { + /// Logical input path. + path: String, + /// Operating-system error. + source: std::io::Error, + }, + /// An opened source could not be read completely. + #[error("could not read {path}: {source}")] + Read { + /// Logical input path. + path: String, + /// Input/output error. + source: std::io::Error, + }, +} + +impl SourceReadError { + /// Stable operation identifier for process diagnostics. + #[must_use] + pub const fn operation(&self) -> &'static str { + match self { + Self::Open { .. } => "source-open", + Self::Read { .. } => "source-read", + } + } +} + +/// Read exact bytes from a UTF-8 path using an explicit ambient authority. +/// +/// # Errors +/// +/// Returns [`SourceReadError`] when the source cannot be opened or read. +pub fn read_path(path: &Utf8Path) -> Result, SourceReadError> { + let display_path = path.as_str().to_owned(); + let mut file = + File::open_ambient(path, ambient_authority()).map_err(|source| SourceReadError::Open { + path: display_path.clone(), + source, + })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|source| SourceReadError::Read { + path: display_path, + source, + })?; + Ok(bytes) +} + +/// Read all bytes from an injected standard-input reader. +/// +/// # Errors +/// +/// Returns [`SourceReadError::Read`] when the stream fails. +pub fn read_stdin(reader: &mut (impl std::io::Read + ?Sized)) -> Result, SourceReadError> { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|source| SourceReadError::Read { + path: "standard input".to_owned(), + source, + })?; + Ok(bytes) +} diff --git a/src/application.rs b/src/application.rs new file mode 100644 index 0000000..36164a1 --- /dev/null +++ b/src/application.rs @@ -0,0 +1,299 @@ +//! Application policy for validating bytes and assembling a stable report. + +use sha2::{Digest as _, Sha256}; +use thiserror::Error; + +use crate::{ + domain::{ + ConditionContext, + IncludeFact, + LocationError, + LocationIndex, + ParseDiagnostic, + ParseReport, + ParseStatus, + ParseSummary, + RecipeFact, + RuleFact, + SCHEMA_VERSION, + SourceIdentity, + ToolIdentity, + VariableFact, + }, + ports::{ConditionObservation, MakefileParser, ParserPortError, SyntaxObservation}, +}; + +/// Failure before a report can be produced. +#[derive(Debug, Error)] +pub enum ParseApplicationError { + /// Source bytes are not UTF-8. + #[error("source is not valid UTF-8: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + /// Parser adapter invariant failed. + #[error(transparent)] + Parser(#[from] ParserPortError), + /// A parser-owned span could not be mapped. + #[error(transparent)] + Location(#[from] LocationError), +} + +/// Parse exact bytes under a caller-supplied logical source name. +/// +/// # Errors +/// +/// Returns [`ParseApplicationError`] for invalid UTF-8 or parser invariants. +/// +/// # Examples +/// +/// ``` +/// use makeutil::{adapters::MakefileLosslessParser, parse_source}; +/// +/// let report = parse_source(b"all:\n\techo ok\n", "Makefile", &MakefileLosslessParser)?; +/// assert_eq!( +/// report +/// .rules +/// .first() +/// .and_then(|rule| rule.targets.first()) +/// .map(String::as_str), +/// Some("all"), +/// ); +/// # Ok::<(), makeutil::ParseApplicationError>(()) +/// ``` +pub fn parse_source( + source: &[u8], + logical_path: &str, + parser: &impl MakefileParser, +) -> Result { + let source_text = std::str::from_utf8(source)?; + let outcome = parser.parse(source_text)?; + let locations = LocationIndex::new(source_text); + let mut assembly = ReportAssembly::default(); + for observation in outcome.observations { + assembly.push(observation, &locations)?; + } + let status = assembly.status(); + Ok(ParseReport { + schema_version: SCHEMA_VERSION, + tool: ToolIdentity::default(), + source: SourceIdentity { + path: logical_path.to_owned(), + sha256: data_encoding::HEXLOWER.encode(&Sha256::digest(source)), + byte_length: source.len(), + }, + parse: ParseSummary { + status, + diagnostics: assembly.diagnostics, + }, + rules: assembly.rules, + variables: assembly.variables, + includes: assembly.includes, + }) +} + +#[derive(Default)] +struct ReportAssembly { + rules: Vec, + variables: Vec, + includes: Vec, + diagnostics: Vec, + fact_ordinal: usize, +} + +impl ReportAssembly { + fn push( + &mut self, + observation: SyntaxObservation, + locations: &LocationIndex<'_>, + ) -> Result<(), LocationError> { + match observation { + SyntaxObservation::Rule { + targets, + prerequisites, + double_colon, + conditions, + recipes, + span, + } => self.push_rule( + RuleParts { + targets, + prerequisites, + double_colon, + conditions, + recipes, + span, + }, + locations, + ), + SyntaxObservation::Variable { + name, + operator, + raw_value, + exported, + overridden, + define_block, + conditions, + span, + } => self.push_variable( + VariableParts { + name, + operator, + raw_value, + exported, + overridden, + define_block, + conditions, + span, + }, + locations, + ), + SyntaxObservation::Include { + raw_path, + optional, + conditions, + span, + } => self.push_include( + IncludeParts { + raw_path, + optional, + conditions, + span, + }, + locations, + ), + SyntaxObservation::Diagnostic { + message, + code, + span, + } => { + self.diagnostics.push(ParseDiagnostic { + message, + code, + location: locations.locate(span)?, + }); + Ok(()) + } + } + } + + fn push_rule( + &mut self, + parts: RuleParts, + locations: &LocationIndex<'_>, + ) -> Result<(), LocationError> { + let located_recipes = parts + .recipes + .into_iter() + .enumerate() + .map(|(ordinal, recipe)| { + Ok(RecipeFact { + ordinal, + text: recipe.text, + silent: recipe.silent, + ignore_errors: recipe.ignore_errors, + always_execute: recipe.always_execute, + location: locations.locate(recipe.span)?, + }) + }) + .collect::, LocationError>>()?; + self.rules.push(RuleFact { + ordinal: self.fact_ordinal, + targets: parts.targets, + prerequisites: parts.prerequisites, + double_colon: parts.double_colon, + conditions: locate_conditions(parts.conditions, locations)?, + recipes: located_recipes, + location: locations.locate(parts.span)?, + }); + self.fact_ordinal += 1; + Ok(()) + } + + fn push_variable( + &mut self, + parts: VariableParts, + locations: &LocationIndex<'_>, + ) -> Result<(), LocationError> { + self.variables.push(VariableFact { + ordinal: self.fact_ordinal, + name: parts.name, + operator: parts.operator, + raw_value: parts.raw_value, + exported: parts.exported, + overridden: parts.overridden, + define_block: parts.define_block, + conditions: locate_conditions(parts.conditions, locations)?, + location: locations.locate(parts.span)?, + }); + self.fact_ordinal += 1; + Ok(()) + } + + fn push_include( + &mut self, + parts: IncludeParts, + locations: &LocationIndex<'_>, + ) -> Result<(), LocationError> { + self.includes.push(IncludeFact { + ordinal: self.fact_ordinal, + dynamic: parts.raw_path.contains('$'), + raw_path: parts.raw_path, + optional: parts.optional, + conditions: locate_conditions(parts.conditions, locations)?, + location: locations.locate(parts.span)?, + }); + self.fact_ordinal += 1; + Ok(()) + } + + const fn status(&self) -> ParseStatus { + if self.diagnostics.is_empty() { + ParseStatus::Complete + } else { + ParseStatus::Recovered + } + } +} + +struct RuleParts { + targets: Vec, + prerequisites: Vec, + double_colon: bool, + conditions: Vec, + recipes: Vec, + span: crate::domain::SourceSpan, +} + +struct VariableParts { + name: String, + operator: String, + raw_value: String, + exported: bool, + overridden: bool, + define_block: bool, + conditions: Vec, + span: crate::domain::SourceSpan, +} + +struct IncludeParts { + raw_path: String, + optional: bool, + conditions: Vec, + span: crate::domain::SourceSpan, +} + +fn locate_conditions( + conditions: Vec, + locations: &LocationIndex<'_>, +) -> Result, LocationError> { + conditions + .into_iter() + .map(|condition| { + Ok(ConditionContext { + kind: condition.kind, + expression: condition.expression, + branch: condition.branch, + location: locations.locate(condition.span)?, + }) + }) + .collect() +} diff --git a/src/domain/location.rs b/src/domain/location.rs new file mode 100644 index 0000000..7c14628 --- /dev/null +++ b/src/domain/location.rs @@ -0,0 +1,138 @@ +//! Convert zero-based byte spans into stable one-based display locations. + +use serde::Serialize; +use thiserror::Error; + +/// A validated zero-based, end-exclusive byte span. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SourceSpan { + /// First byte included in the span. + pub start: usize, + /// First byte excluded from the span. + pub end: usize, +} + +impl SourceSpan { + /// Construct a span for offsets in a source of `source_length` bytes. + /// + /// # Errors + /// + /// Returns [`LocationError`] when the offsets are reversed or out of bounds. + pub const fn new( + start: usize, + end: usize, + source_length: usize, + ) -> Result { + if start > end || end > source_length { + return Err(LocationError::InvalidSpan { + start, + end, + source_length, + }); + } + Ok(Self { start, end }) + } +} + +/// Complete machine and display location for a source span. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceLocation { + /// Zero-based first byte. + pub start_byte: usize, + /// Zero-based exclusive end byte. + pub end_byte: usize, + /// One-based first line. + pub start_line: usize, + /// One-based byte column on the first line. + pub start_column: usize, + /// One-based line at the exclusive end. + pub end_line: usize, + /// One-based byte column at the exclusive end. + pub end_column: usize, +} + +/// Failure to map an invalid byte span. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LocationError { + /// Span ordering or bounds do not fit the source. + #[error("invalid source span {start}..{end} for {source_length} bytes")] + InvalidSpan { + /// Requested start offset. + start: usize, + /// Requested exclusive end offset. + end: usize, + /// Available source length. + source_length: usize, + }, + /// An offset splits a UTF-8 code point. + #[error("source offset {offset} is not a UTF-8 boundary")] + NonUtf8Boundary { + /// Invalid byte offset. + offset: usize, + }, +} + +/// Reusable index from byte offsets to line and byte-column positions. +#[derive(Debug, Clone)] +pub struct LocationIndex<'source> { + source: &'source str, + line_starts: Vec, +} + +impl<'source> LocationIndex<'source> { + /// Index a UTF-8 source. + #[must_use] + pub fn new(source: &'source str) -> Self { + let line_starts = std::iter::once(0) + .chain( + source + .bytes() + .enumerate() + .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset + 1)), + ) + .collect(); + Self { + source, + line_starts, + } + } + + /// Map a validated byte span to its stable location. + /// + /// # Errors + /// + /// Returns [`LocationError`] for invalid bounds or split UTF-8 code points. + pub fn locate(&self, span: SourceSpan) -> Result { + let validated = SourceSpan::new(span.start, span.end, self.source.len())?; + self.require_boundary(validated.start)?; + self.require_boundary(validated.end)?; + let (start_line, start_column) = self.position(validated.start); + let (end_line, end_column) = self.position(validated.end); + Ok(SourceLocation { + start_byte: validated.start, + end_byte: validated.end, + start_line, + start_column, + end_line, + end_column, + }) + } + + const fn require_boundary(&self, offset: usize) -> Result<(), LocationError> { + if self.source.is_char_boundary(offset) { + Ok(()) + } else { + Err(LocationError::NonUtf8Boundary { offset }) + } + } + + fn position(&self, offset: usize) -> (usize, usize) { + let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1; + let line_start = self + .line_starts + .get(line_index) + .copied() + .unwrap_or_default(); + (line_index + 1, offset - line_start + 1) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..2b0bf45 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,192 @@ +//! Stable report types owned by `makeutil` rather than its parser dependency. + +mod location; + +pub use location::{LocationError, LocationIndex, SourceLocation, SourceSpan}; +use serde::Serialize; + +/// Version of the JSON integration contract. +pub const SCHEMA_VERSION: u8 = 1; + +/// Identity of the tool and parser used to produce a report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolIdentity { + /// Executable name. + pub name: &'static str, + /// Executable package version. + pub version: &'static str, + /// Parser crate name. + pub parser: &'static str, + /// Exactly pinned parser crate version. + pub parser_version: &'static str, +} + +impl Default for ToolIdentity { + fn default() -> Self { + Self { + name: "makeutil", + version: env!("CARGO_PKG_VERSION"), + parser: "makefile-lossless", + parser_version: "0.3.40", + } + } +} + +/// Exact identity of the parsed source. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceIdentity { + /// Caller-supplied logical source path. + pub path: String, + /// Lower-case hexadecimal SHA-256 digest of the exact bytes. + pub sha256: String, + /// Exact source length in bytes. + pub byte_length: usize, +} + +/// Whether parsing was complete or recovered through diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ParseStatus { + /// No parser diagnostics were emitted. + Complete, + /// A partial syntax tree was recovered with diagnostics. + Recovered, +} + +/// One parser diagnostic attached to source. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParseDiagnostic { + /// Human-readable upstream message. + pub message: String, + /// Optional upstream diagnostic code. + pub code: Option, + /// Source range associated with the problem. + pub location: SourceLocation, +} + +/// Parse classification and ordered diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParseSummary { + /// Complete or recovered classification. + pub status: ParseStatus, + /// Diagnostics in upstream order. + pub diagnostics: Vec, +} + +/// Conditional branch ancestry for a fact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConditionContext { + /// GNU Make conditional keyword. + pub kind: String, + /// Unexpanded condition expression. + pub expression: String, + /// Branch containing the fact. + pub branch: ConditionBranch, + /// Range of the opening or else directive. + pub location: SourceLocation, +} + +/// Branch of a conditional. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ConditionBranch { + /// The opening conditional arm. + If, + /// The else arm. + Else, +} + +/// One source-faithful recipe line. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecipeFact { + /// Zero-based position within its rule. + pub ordinal: usize, + /// Recipe content without the leading tab or line ending. + pub text: String, + /// Whether the recipe has the `@` modifier. + pub silent: bool, + /// Whether the recipe has the `-` modifier. + pub ignore_errors: bool, + /// Whether the recipe has the `+` modifier. + pub always_execute: bool, + /// Complete physical recipe range. + pub location: SourceLocation, +} + +/// One explicit rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuleFact { + /// Global source-order position among facts. + pub ordinal: usize, + /// Unexpanded targets. + pub targets: Vec, + /// Unexpanded prerequisites. + pub prerequisites: Vec, + /// Whether the rule uses `::`. + pub double_colon: bool, + /// Outer-to-inner conditional ancestry. + pub conditions: Vec, + /// Recipes in source order. + pub recipes: Vec, + /// Complete rule range. + pub location: SourceLocation, +} + +/// One variable definition or define block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VariableFact { + /// Global source-order position among facts. + pub ordinal: usize, + /// Variable name. + pub name: String, + /// Source assignment operator. + pub operator: String, + /// Unexpanded source value. + pub raw_value: String, + /// Whether the `export` modifier is present. + pub exported: bool, + /// Whether the `override` modifier is present. + pub overridden: bool, + /// Whether this is a `define` block. + pub define_block: bool, + /// Outer-to-inner conditional ancestry. + pub conditions: Vec, + /// Complete definition range. + pub location: SourceLocation, +} + +/// One include directive, never followed by the parser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IncludeFact { + /// Global source-order position among facts. + pub ordinal: usize, + /// Unexpanded include expression. + pub raw_path: String, + /// Whether a missing include is allowed. + pub optional: bool, + /// Whether the expression contains a Make expansion marker. + pub dynamic: bool, + /// Outer-to-inner conditional ancestry. + pub conditions: Vec, + /// Complete directive range. + pub location: SourceLocation, +} + +/// Versioned JSON document emitted by `makeutil parse`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParseReport { + /// Stable schema version, currently 1. + pub schema_version: u8, + /// Tool and parser identity. + pub tool: ToolIdentity, + /// Exact input identity. + pub source: SourceIdentity, + /// Parse status and diagnostics. + pub parse: ParseSummary, + /// Explicit rules in source order. + pub rules: Vec, + /// Variable definitions in source order. + pub variables: Vec, + /// Include directives in source order. + pub includes: Vec, +} diff --git a/src/lib.rs b/src/lib.rs index f6bfa30..f618f71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,9 @@ -//! `makeutil` library support for the application crate. +//! Parse one GNU Makefile into deterministic, versioned JSON facts. -// TODO: Replace this stub when application logic moves behind the executable. -/// Returns the generated application greeting. -/// -/// # Examples -/// -/// ``` -/// use makeutil::greet; -/// -/// assert_eq!(greet(), "Hello from makeutil!"); -/// ``` -#[must_use] -pub const fn greet() -> &'static str { "Hello from makeutil!" } +pub mod adapters; +pub mod application; +pub mod domain; +pub mod ports; + +pub use application::{ParseApplicationError, parse_source}; +pub use domain::ParseReport; diff --git a/src/main.rs b/src/main.rs index c410c5b..81e8dc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,16 @@ //! `makeutil` application entry point. -// TODO: Remove when replacing app scaffolding -// (docs/execplans/rust-project-enhancements.md). -/// Application entry point. -#[expect( - clippy::print_stdout, - reason = "temporary app stub tracked in docs/execplans/rust-project-enhancements.md" -)] -fn main() { - println!("Hello from makeutil!"); +use std::process::ExitCode; + +use makeutil::adapters::cli::run_from; + +/// Compose process streams and return the classified exit code. +fn main() -> ExitCode { + let outcome = run_from( + std::env::args_os(), + &mut std::io::stdin().lock(), + &mut std::io::stdout().lock(), + &mut std::io::stderr().lock(), + ); + ExitCode::from(outcome.exit_code) } diff --git a/src/ports.rs b/src/ports.rs new file mode 100644 index 0000000..73762e8 --- /dev/null +++ b/src/ports.rs @@ -0,0 +1,126 @@ +//! Domain-owned port isolating syntax collection from the upstream parser. + +use thiserror::Error; + +use crate::domain::{ConditionBranch, LocationError, SourceSpan}; + +/// Parser output expressed only in makeutil-owned observations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParserOutcome { + /// Ordered facts and diagnostics. + pub observations: Vec, +} + +/// Conditional ancestry before display locations are calculated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConditionObservation { + /// GNU Make conditional keyword. + pub kind: String, + /// Unexpanded expression. + pub expression: String, + /// Branch containing the fact. + pub branch: ConditionBranch, + /// Opening or else directive span. + pub span: SourceSpan, +} + +/// Recipe syntax associated with a rule observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecipeObservation { + /// Recipe content without the tab or line ending. + pub text: String, + /// Whether `@` is present. + pub silent: bool, + /// Whether `-` is present. + pub ignore_errors: bool, + /// Whether `+` is present. + pub always_execute: bool, + /// Complete physical span. + pub span: SourceSpan, +} + +/// One ordered syntax fact or diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntaxObservation { + /// Explicit rule syntax. + Rule { + /// Unexpanded targets. + targets: Vec, + /// Unexpanded prerequisites. + prerequisites: Vec, + /// Whether `::` is used. + double_colon: bool, + /// Conditional ancestry. + conditions: Vec, + /// Ordered recipe syntax. + recipes: Vec, + /// Complete rule span. + span: SourceSpan, + }, + /// Variable definition syntax. + Variable { + /// Variable name. + name: String, + /// Assignment operator. + operator: String, + /// Unexpanded value. + raw_value: String, + /// Whether exported. + exported: bool, + /// Whether overridden. + overridden: bool, + /// Whether a define block. + define_block: bool, + /// Conditional ancestry. + conditions: Vec, + /// Complete definition span. + span: SourceSpan, + }, + /// Include directive syntax. + Include { + /// Unexpanded path expression. + raw_path: String, + /// Whether missing files are allowed. + optional: bool, + /// Conditional ancestry. + conditions: Vec, + /// Complete directive span. + span: SourceSpan, + }, + /// Parser diagnostic. + Diagnostic { + /// Human-readable message. + message: String, + /// Optional upstream code. + code: Option, + /// Positioned or derived span. + span: SourceSpan, + }, +} + +/// Parser adapter invariant failure. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ParserPortError { + /// The upstream tree did not render back to the original source. + #[error("upstream concrete syntax tree did not round-trip")] + RoundTripMismatch, + /// An upstream range violated the source contract. + #[error(transparent)] + InvalidLocation(#[from] LocationError), + /// A required syntax accessor was absent. + #[error("required {field} accessor was absent")] + MissingField { + /// Stable semantic field name. + field: &'static str, + }, +} + +/// Parses source without exposing upstream parser types. +pub trait MakefileParser { + /// Collect ordered GNU Make syntax observations. + /// + /// # Errors + /// + /// Returns [`ParserPortError`] when an adapter invariant is violated. + fn parse(&self, source: &str) -> Result; +} diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs new file mode 100644 index 0000000..63ed177 --- /dev/null +++ b/tests/cli_e2e.rs @@ -0,0 +1,99 @@ +//! Black-box process contract tests for the public executable. + +use std::io::Write as _; + +use assert_cmd::Command; +use rstest::rstest; + +#[rstest] +fn complete_path_emits_one_json_document() { + let output = Command::cargo_bin("makeutil") + .expect("binary should build") + .args(["parse", "tests/fixtures/makefiles/all-facts.mk"]) + .output() + .expect("binary should run"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + assert_eq!(output.stdout.last(), Some(&b'\n')); + let document: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); + assert_eq!( + document + .get("schema_version") + .and_then(serde_json::Value::as_u64), + Some(1) + ); +} + +#[rstest] +fn recovered_path_exits_one_with_json() { + let output = Command::cargo_bin("makeutil") + .expect("binary should build") + .args(["parse", "tests/fixtures/makefiles/recovered.mk"]) + .output() + .expect("binary should run"); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + let document: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); + assert_eq!( + document + .pointer("/parse/status") + .and_then(serde_json::Value::as_str), + Some("recovered") + ); +} + +#[rstest] +#[case(&["parse", "-"][..])] +#[case(&["parse"][..])] +fn invalid_invocation_exits_two(#[case] arguments: &[&str]) { + let output = Command::cargo_bin("makeutil") + .expect("binary should build") + .args(arguments) + .output() + .expect("binary should run"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); +} + +#[rstest] +fn hostile_source_is_inert() { + let temporary = tempfile::tempdir().expect("temporary directory should exist"); + let sentinel = temporary.path().join("sentinel"); + let source = format!( + "X := $(shell touch {})\nall:\n\ttouch {}\n", + sentinel.display(), + sentinel.display() + ); + let mut command = Command::cargo_bin("makeutil").expect("binary should build"); + let output = command + .args(["parse", "--stdin-filename", "Makefile", "-"]) + .write_stdin(source) + .output() + .expect("binary should run"); + assert_eq!(output.status.code(), Some(0)); + assert!(!sentinel.exists()); +} + +#[rstest] +fn invalid_utf8_is_a_fatal_source_error() { + let mut source = tempfile::NamedTempFile::new().expect("temporary file should exist"); + source + .write_all(&[0xff]) + .expect("fixture bytes should be written"); + let path = source + .path() + .to_str() + .expect("temporary path should be UTF-8"); + let output = Command::cargo_bin("makeutil") + .expect("binary should build") + .args(["parse", path]) + .output() + .expect("binary should run"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("makeutil: source-utf8:")); +} diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs new file mode 100644 index 0000000..809ca55 --- /dev/null +++ b/tests/domain_contract.rs @@ -0,0 +1,164 @@ +//! Pure domain and application contract tests. + +use makeutil::{ + adapters::MakefileLosslessParser, + domain::{LocationIndex, ParseStatus, SourceSpan}, + parse_source, +}; +use pretty_assertions::assert_eq; +use proptest::prelude::*; +use rstest::rstest; + +#[rstest] +#[case("", SourceSpan { start: 0, end: 0 }, (1, 1, 1, 1))] +#[case("a\nb", SourceSpan { start: 2, end: 3 }, (2, 1, 2, 2))] +#[case("a\r\nb", SourceSpan { start: 1, end: 3 }, (1, 2, 2, 1))] +#[case("é\n", SourceSpan { start: 0, end: 2 }, (1, 1, 1, 3))] +fn locations_are_one_based_byte_positions( + #[case] source: &str, + #[case] span: SourceSpan, + #[case] expected: (usize, usize, usize, usize), +) { + let location = LocationIndex::new(source) + .locate(span) + .expect("test span should be valid"); + assert_eq!( + ( + location.start_line, + location.start_column, + location.end_line, + location.end_column, + ), + expected + ); +} + +#[rstest] +fn application_builds_ordered_facts() { + let source = include_bytes!("fixtures/makefiles/all-facts.mk"); + let report = + parse_source(source, "Makefile", &MakefileLosslessParser).expect("fixture should parse"); + + assert_eq!(report.parse.status, ParseStatus::Complete); + assert_eq!(report.variables.first().map(|fact| fact.ordinal), Some(0)); + assert_eq!(report.includes.first().map(|fact| fact.ordinal), Some(1)); + assert_eq!( + report + .rules + .iter() + .map(|fact| fact.ordinal) + .collect::>(), + [2, 3] + ); + assert_eq!( + report.rules.first().map(|fact| fact.double_colon), + Some(true) + ); + let recipe = report.rules.first().and_then(|fact| fact.recipes.first()); + assert_eq!(recipe.map(|fact| fact.silent), Some(true)); + assert_eq!(recipe.map(|fact| fact.ignore_errors), Some(true)); + assert_eq!(recipe.map(|fact| fact.always_execute), Some(true)); +} + +#[rstest] +fn recovered_parse_retains_facts_and_diagnostics() { + let source = include_bytes!("fixtures/makefiles/recovered.mk"); + let report = parse_source(source, "broken.mk", &MakefileLosslessParser) + .expect("recovered parse should produce a report"); + + assert_eq!(report.parse.status, ParseStatus::Recovered); + assert!(!report.parse.diagnostics.is_empty()); + assert_eq!( + report.variables.first().map(|fact| fact.name.as_str()), + Some("GOOD") + ); + assert!( + report + .rules + .iter() + .any(|fact| fact.targets.iter().any(|target| target == "valid")) + ); +} + +#[rstest] +#[case("A = one\n", "=", "one")] +#[case("A := two\n", ":=", "two")] +#[case("A ::= three\n", "::=", "three")] +#[case("A :::= four\n", ":::=", "four")] +#[case("A += five\n", "+=", "five")] +#[case("A ?= six\n", "?=", "six")] +fn assignment_operators_remain_source_faithful( + #[case] source: &str, + #[case] operator: &str, + #[case] raw_value: &str, +) { + let report = parse_source(source.as_bytes(), "Makefile", &MakefileLosslessParser) + .expect("assignment should parse"); + let variable = report + .variables + .first() + .expect("one variable should be reported"); + assert_eq!(variable.operator, operator); + assert_eq!(variable.raw_value, raw_value); +} + +#[rstest] +fn shell_assignment_parser_gap_remains_explicit() { + let report = parse_source(b"A != printf seven\n", "Makefile", &MakefileLosslessParser) + .expect("upstream recovery should still produce a report"); + + assert_eq!(report.parse.status, ParseStatus::Recovered); + assert!(report.variables.is_empty()); + assert!(!report.parse.diagnostics.is_empty()); +} + +#[rstest] +fn nested_conditions_preserve_outer_to_inner_branches() { + let source = b"ifdef OUTER\nifeq ($(A),yes)\nX = one\nelse\nX = two\nendif\nendif\n"; + let report = parse_source(source, "Makefile", &MakefileLosslessParser) + .expect("nested conditionals should parse"); + let branches = report + .variables + .iter() + .map(|variable| { + variable + .conditions + .iter() + .map(|condition| condition.branch) + .collect() + }) + .collect::>>(); + assert_eq!( + branches, + [ + vec![ + makeutil::domain::ConditionBranch::If, + makeutil::domain::ConditionBranch::If + ], + vec![ + makeutil::domain::ConditionBranch::If, + makeutil::domain::ConditionBranch::Else + ], + ] + ); +} + +proptest! { + #[test] + fn valid_ascii_spans_are_monotonic( + source in "[ -~\\n\\r]{0,128}", + first in 0usize..129, + second in 0usize..129, + ) { + let start = first.min(second).min(source.len()); + let end = first.max(second).min(source.len()); + let location = LocationIndex::new(&source) + .locate(SourceSpan { start, end }) + .expect("ASCII offsets are UTF-8 boundaries"); + prop_assert!(location.start_byte <= location.end_byte); + prop_assert!(location.start_line <= location.end_line); + prop_assert!(location.start_column >= 1); + prop_assert!(location.end_column >= 1); + prop_assert_eq!(source.get(start..end).map(str::len), Some(end - start)); + } +} diff --git a/tests/features/parse.feature b/tests/features/parse.feature new file mode 100644 index 0000000..8766ab3 --- /dev/null +++ b/tests/features/parse.feature @@ -0,0 +1,21 @@ +Feature: Parse one GNU Makefile into JSON facts + + Scenario: Parse a complete Makefile by path + Given a complete GNU Makefile fixture + When makeutil parses the fixture by path + Then stdout contains one schema version 1 JSON document + And the process exits with code 0 + And stderr is empty + + Scenario: Parse complete source from standard input + Given complete GNU Makefile source on standard input + When makeutil parses dash with stdin filename Makefile + Then the report source path is Makefile + And the process exits with code 0 + + Scenario: Reject a missing input path + Given a path that does not exist + When makeutil attempts to parse the missing path + Then stdout is empty + And stderr reports the source-open operation + And the process exits with code 2 diff --git a/tests/fixtures/makefiles/all-facts.mk b/tests/fixtures/makefiles/all-facts.mk new file mode 100644 index 0000000..d2bb22f --- /dev/null +++ b/tests/fixtures/makefiles/all-facts.mk @@ -0,0 +1,10 @@ +MODE ?= debug +include $(CONFIG_DIR)/common.mk + +ifdef CI +check:: prepare + @-+cargo test +else +check: local + echo local +endif diff --git a/tests/fixtures/makefiles/recovered.mk b/tests/fixtures/makefiles/recovered.mk new file mode 100644 index 0000000..66a8297 --- /dev/null +++ b/tests/fixtures/makefiles/recovered.mk @@ -0,0 +1,4 @@ +broken rule without colon +GOOD = retained +valid: + echo retained diff --git a/tests/output_failures.rs b/tests/output_failures.rs new file mode 100644 index 0000000..40e4b8b --- /dev/null +++ b/tests/output_failures.rs @@ -0,0 +1,32 @@ +//! Injected output failures verify the stable writer error boundary. + +use makeutil::adapters::cli::run_from; +use rstest::rstest; + +struct FailingWriter; + +impl std::io::Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } +} + +#[rstest] +fn broken_stdout_exits_two_with_stable_operation() { + let mut stdin = b"all:\n\techo ok\n".as_slice(); + let mut stdout = FailingWriter; + let mut stderr = Vec::new(); + let outcome = run_from( + ["makeutil", "parse", "--stdin-filename", "Makefile", "-"], + &mut stdin, + &mut stdout, + &mut stderr, + ); + assert_eq!(outcome.exit_code, 2); + assert!(String::from_utf8_lossy(&stderr).contains("makeutil: stdout-write:")); +} diff --git a/tests/parse_bdd.rs b/tests/parse_bdd.rs new file mode 100644 index 0000000..b9f934c --- /dev/null +++ b/tests/parse_bdd.rs @@ -0,0 +1,135 @@ +//! Behavioural acceptance tests for the parse command. + +use makeutil::adapters::cli::run_from; +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; + +#[derive(Default)] +struct World { + arguments: Vec, + stdin: Vec, + stdout: Vec, + stderr: Vec, + exit_code: Option, +} + +#[fixture] +fn world() -> World { + World { + arguments: Vec::new(), + stdin: Vec::new(), + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: None, + } +} + +#[given("a complete GNU Makefile fixture")] +fn complete_fixture(world: &mut World) { + world.arguments = vec![ + "makeutil".to_owned(), + "parse".to_owned(), + "tests/fixtures/makefiles/all-facts.mk".to_owned(), + ]; +} + +#[given("complete GNU Makefile source on standard input")] +fn complete_stdin(world: &mut World) { world.stdin = b"all:\n\techo ok\n".to_vec(); } + +#[given("a path that does not exist")] +fn missing_path(world: &mut World) { + world.arguments = vec![ + "makeutil".to_owned(), + "parse".to_owned(), + "tests/fixtures/makefiles/absent.mk".to_owned(), + ]; +} + +#[when("makeutil parses the fixture by path")] +#[when("makeutil attempts to parse the missing path")] +fn run_path(world: &mut World) { run_world(world); } + +#[when("makeutil parses dash with stdin filename Makefile")] +fn run_stdin(world: &mut World) { + world.arguments = vec![ + "makeutil".to_owned(), + "parse".to_owned(), + "--stdin-filename".to_owned(), + "Makefile".to_owned(), + "-".to_owned(), + ]; + run_world(world); +} + +#[then("stdout contains one schema version 1 JSON document")] +fn schema_version(world: &World) -> googletest::Result<()> { + let document: serde_json::Value = serde_json::from_slice(&world.stdout)?; + assert_eq!( + document + .get("schema_version") + .and_then(serde_json::Value::as_u64), + Some(1) + ); + Ok(()) +} + +#[then("the report source path is Makefile")] +fn stdin_path(world: &World) -> googletest::Result<()> { + let document: serde_json::Value = serde_json::from_slice(&world.stdout)?; + assert_eq!( + document + .pointer("/source/path") + .and_then(serde_json::Value::as_str), + Some("Makefile") + ); + Ok(()) +} + +#[then("the process exits with code {expected:u8}")] +fn exit_code(world: &World, expected: u8) { + assert_eq!(world.exit_code, Some(expected)); +} + +#[then("stderr is empty")] +fn empty_stderr(world: &World) { + assert!(world.stderr.is_empty()); +} + +#[then("stdout is empty")] +fn empty_stdout(world: &World) { + assert!(world.stdout.is_empty()); +} + +#[then("stderr reports the source-open operation")] +fn source_open(world: &World) { + assert!(String::from_utf8_lossy(&world.stderr).contains("makeutil: source-open:")); +} + +fn run_world(world: &mut World) { + let mut stdin = world.stdin.as_slice(); + let outcome = run_from( + world.arguments.clone(), + &mut stdin, + &mut world.stdout, + &mut world.stderr, + ); + world.exit_code = Some(outcome.exit_code); +} + +#[scenario( + path = "tests/features/parse.feature", + name = "Parse a complete Makefile by path" +)] +fn parse_path(_world: World) {} + +#[scenario( + path = "tests/features/parse.feature", + name = "Parse complete source from standard input" +)] +fn parse_stdin(_world: World) {} + +#[scenario( + path = "tests/features/parse.feature", + name = "Reject a missing input path" +)] +fn reject_missing(_world: World) {} diff --git a/tests/report_schema.rs b/tests/report_schema.rs new file mode 100644 index 0000000..7160873 --- /dev/null +++ b/tests/report_schema.rs @@ -0,0 +1,58 @@ +//! JSON Schema and snapshot tests for the stable report contract. + +use makeutil::{adapters::MakefileLosslessParser, parse_source}; +use rstest::rstest; + +fn schema() -> Result { + serde_json::from_str(include_str!("../schemas/makeutil.parse.v1.schema.json")) +} + +#[rstest] +#[case(include_bytes!("fixtures/makefiles/all-facts.mk"), "complete.mk")] +#[case(include_bytes!("fixtures/makefiles/recovered.mk"), "recovered.mk")] +fn reports_validate_against_schema( + #[case] source: &[u8], + #[case] path: &str, +) -> Result<(), Box> { + let report = parse_source(source, path, &MakefileLosslessParser)?; + let document = serde_json::to_value(report)?; + let validator = jsonschema::validator_for(&schema()?)?; + if validator.is_valid(&document) { + Ok(()) + } else { + Err("report did not validate against schema".into()) + } +} + +#[rstest] +fn malformed_near_miss_is_rejected() -> Result<(), Box> { + let validator = jsonschema::validator_for(&schema()?)?; + let malformed = serde_json::json!({"schema_version": 1, "unexpected": true}); + if validator.is_valid(&malformed) { + Err("malformed near-miss unexpectedly validated".into()) + } else { + Ok(()) + } +} + +#[rstest] +fn all_fact_variants_have_stable_json() -> Result<(), Box> { + let report = parse_source( + include_bytes!("fixtures/makefiles/all-facts.mk"), + "Makefile", + &MakefileLosslessParser, + )?; + insta::assert_json_snapshot!(report); + Ok(()) +} + +#[rstest] +fn recovered_output_has_stable_json() -> Result<(), Box> { + let report = parse_source( + include_bytes!("fixtures/makefiles/recovered.mk"), + "recovered.mk", + &MakefileLosslessParser, + )?; + insta::assert_json_snapshot!(report); + Ok(()) +} diff --git a/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap b/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap new file mode 100644 index 0000000..a4da909 --- /dev/null +++ b/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap @@ -0,0 +1,161 @@ +--- +source: tests/report_schema.rs +expression: report +--- +{ + "schema_version": 1, + "tool": { + "name": "makeutil", + "version": "0.1.0", + "parser": "makefile-lossless", + "parser_version": "0.3.40" + }, + "source": { + "path": "Makefile", + "sha256": "4ad9a5a5d6a76c84f0aa2b25b174fd3f49a4046718bf30db1d7fd39ee8a98b04", + "byte_length": 123 + }, + "parse": { + "status": "complete", + "diagnostics": [] + }, + "rules": [ + { + "ordinal": 2, + "targets": [ + "check" + ], + "prerequisites": [ + "prepare" + ], + "double_colon": true, + "conditions": [ + { + "kind": "ifdef", + "expression": "CI", + "branch": "if", + "location": { + "start_byte": 47, + "end_byte": 56, + "start_line": 4, + "start_column": 1, + "end_line": 5, + "end_column": 1 + } + } + ], + "recipes": [ + { + "ordinal": 0, + "text": "@-+cargo test", + "silent": true, + "ignore_errors": true, + "always_execute": true, + "location": { + "start_byte": 72, + "end_byte": 87, + "start_line": 6, + "start_column": 1, + "end_line": 7, + "end_column": 1 + } + } + ], + "location": { + "start_byte": 56, + "end_byte": 87, + "start_line": 5, + "start_column": 1, + "end_line": 7, + "end_column": 1 + } + }, + { + "ordinal": 3, + "targets": [ + "check" + ], + "prerequisites": [ + "local" + ], + "double_colon": false, + "conditions": [ + { + "kind": "ifdef", + "expression": "CI", + "branch": "else", + "location": { + "start_byte": 87, + "end_byte": 91, + "start_line": 7, + "start_column": 1, + "end_line": 7, + "end_column": 5 + } + } + ], + "recipes": [ + { + "ordinal": 0, + "text": "echo local", + "silent": false, + "ignore_errors": false, + "always_execute": false, + "location": { + "start_byte": 105, + "end_byte": 117, + "start_line": 9, + "start_column": 1, + "end_line": 10, + "end_column": 1 + } + } + ], + "location": { + "start_byte": 92, + "end_byte": 117, + "start_line": 8, + "start_column": 1, + "end_line": 10, + "end_column": 1 + } + } + ], + "variables": [ + { + "ordinal": 0, + "name": "MODE", + "operator": "?=", + "raw_value": "debug", + "exported": false, + "overridden": false, + "define_block": false, + "conditions": [], + "location": { + "start_byte": 0, + "end_byte": 14, + "start_line": 1, + "start_column": 1, + "end_line": 2, + "end_column": 1 + } + } + ], + "includes": [ + { + "ordinal": 1, + "raw_path": "$(CONFIG_DIR)/common.mk", + "optional": false, + "dynamic": true, + "conditions": [], + "location": { + "start_byte": 14, + "end_byte": 46, + "start_line": 2, + "start_column": 1, + "end_line": 3, + "end_column": 1 + } + } + ] +} diff --git a/tests/snapshots/report_schema__recovered_output_has_stable_json.snap b/tests/snapshots/report_schema__recovered_output_has_stable_json.snap new file mode 100644 index 0000000..aa7ef49 --- /dev/null +++ b/tests/snapshots/report_schema__recovered_output_has_stable_json.snap @@ -0,0 +1,113 @@ +--- +source: tests/report_schema.rs +expression: report +--- +{ + "schema_version": 1, + "tool": { + "name": "makeutil", + "version": "0.1.0", + "parser": "makefile-lossless", + "parser_version": "0.3.40" + }, + "source": { + "path": "recovered.mk", + "sha256": "5d575548402b6bc7a68db9c524d10cffea25e50d6eff7de5f42e3b43736f8d8e", + "byte_length": 64 + }, + "parse": { + "status": "recovered", + "diagnostics": [ + { + "message": "expected ':'", + "code": null, + "location": { + "start_byte": 33, + "end_byte": 41, + "start_line": 2, + "start_column": 8, + "end_line": 2, + "end_column": 16 + } + } + ] + }, + "rules": [ + { + "ordinal": 0, + "targets": [ + "broken", + "rule", + "without", + "colon" + ], + "prerequisites": [], + "double_colon": false, + "conditions": [], + "recipes": [], + "location": { + "start_byte": 0, + "end_byte": 26, + "start_line": 1, + "start_column": 1, + "end_line": 2, + "end_column": 1 + } + }, + { + "ordinal": 2, + "targets": [ + "valid" + ], + "prerequisites": [], + "double_colon": false, + "conditions": [], + "recipes": [ + { + "ordinal": 0, + "text": "echo retained", + "silent": false, + "ignore_errors": false, + "always_execute": false, + "location": { + "start_byte": 49, + "end_byte": 64, + "start_line": 4, + "start_column": 1, + "end_line": 5, + "end_column": 1 + } + } + ], + "location": { + "start_byte": 42, + "end_byte": 64, + "start_line": 3, + "start_column": 1, + "end_line": 5, + "end_column": 1 + } + } + ], + "variables": [ + { + "ordinal": 1, + "name": "GOOD", + "operator": "=", + "raw_value": "retained", + "exported": false, + "overridden": false, + "define_block": false, + "conditions": [], + "location": { + "start_byte": 26, + "end_byte": 42, + "start_line": 2, + "start_column": 1, + "end_line": 3, + "end_column": 1 + } + } + ], + "includes": [] +} diff --git a/tests/stub.rs b/tests/stub.rs deleted file mode 100644 index 28c8a7f..0000000 --- a/tests/stub.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Disposable generated-template test stub. -//! -//! This test exists only so `cargo nextest run` has at least one test in a -//! freshly generated project. Delete this file as soon as the project has real -//! functionality and real tests. Do not keep this stub as permanent coverage. - -#[test] -fn replace_this_stub_when_real_tests_exist() { - assert!( - std::env::var_os("CARGO_MANIFEST_DIR").is_some(), - "CARGO_MANIFEST_DIR should be set by Cargo when running tests" - ); -} diff --git a/typos.toml b/typos.toml index ffe8da3..d94628e 100644 --- a/typos.toml +++ b/typos.toml @@ -36,6 +36,7 @@ extend-ignore-re = [ ] [default.extend-words] +"ASO" = "ASO" "Flavored" = "Flavored" "absolutisable" = "absolutizable" "absolutisation" = "absolutization" From b1a448bc2b66beb37f07d5abc243c4beb6f52ad3 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 13 Jul 2026 22:33:26 +0100 Subject: [PATCH 03/29] Pin shell assignment parser fix Resolve `makefile-lossless` through the immutable fork commit that recognizes GNU Make `!=` assignments while retaining the approved exact 0.3.40 version. Restore the source-faithful shell-assignment contract case and record the patch provenance, validation evidence, and upstream retirement condition in the design and contributor documentation. --- Cargo.lock | 3 +- Cargo.toml | 3 ++ docs/design.md | 6 ++- docs/developers-guide.md | 10 ++++- .../adr-0001-single-file-gnu-make-parse.md | 39 +++++++++++++------ docs/users-guide.md | 16 ++++---- tests/domain_contract.rs | 11 +----- typos.toml | 1 + 8 files changed, 55 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a7d320..7101173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,8 +1214,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "makefile-lossless" version = "0.3.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a892a73d2d24783ef4355b310e9d04e5a1c91aabca3c417d60ac524d64e4217a" +source = "git+https://github.com/leynos/makefile-lossless.git?rev=8dd35801b75b332c2ac2f995ae398ef8238559fa#8dd35801b75b332c2ac2f995ae398ef8238559fa" dependencies = [ "log", "rowan", diff --git a/Cargo.toml b/Cargo.toml index 9b77599..e2a5e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ serde_json = "1.0.150" sha2 = "0.11.0" thiserror = "2.0.18" +[patch.crates-io] +makefile-lossless = { git = "https://github.com/leynos/makefile-lossless.git", rev = "8dd35801b75b332c2ac2f995ae398ef8238559fa" } + [dev-dependencies] assert_cmd = "2.2.2" googletest = "0.14.3" diff --git a/docs/design.md b/docs/design.md index 4da6931..9489616 100644 --- a/docs/design.md +++ b/docs/design.md @@ -76,7 +76,11 @@ rewriting, and bindings remain later decisions. The implementation uses [`makefile-lossless`](https://github.com/jelmer/makefile-lossless), initially -pinned to `=0.3.40`. +pinned to `=0.3.40`. A temporary `[patch.crates-io]` override selects commit +`8dd35801b75b332c2ac2f995ae398ef8238559fa` from the `leynos/makefile-lossless` +fork because release 0.3.40 does not lex the documented GNU Make `!=` +assignment operator. Remove the override when an upstream release containing +the fix is adopted; do not replace the immutable commit with a branch name. The crate supplies: diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2eaf8d5..ed774a3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,8 +19,14 @@ external capability requires another port. CLI path and stdin filename values must continue to use OrthoConfig's explicit `ArgMatches` extraction, without file or environment layers. -Tests keep raw Makefile text under `tests/fixtures/makefiles/`. Unit and property -tests exercise the domain, `rstest-bdd` scenarios exercise observable +The exact 0.3.40 parser requirement is temporarily patched to immutable fork +commit `8dd35801b75b332c2ac2f995ae398ef8238559fa`, which adds `!=` lexer +support. Keep the commit pin reproducible. When upgrading to an upstream +release that contains the fix, remove the `[patch.crates-io]` entry and rerun +the complete assignment-operator contract matrix before updating the lockfile. + +Tests keep raw Makefile text under `tests/fixtures/makefiles/`. Unit and +property tests exercise the domain, `rstest-bdd` scenarios exercise observable behaviour, black-box tests spawn the binary, and `insta` plus the JSON Schema freeze the integration contract. diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 0c14860..bb7a3ce 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -4,7 +4,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` must be kept up to date as work proceeds. -Status: BLOCKED AT UPSTREAM CONTRACT GATE. +Status: APPROVED / IN PROGRESS. ## Approval @@ -171,6 +171,13 @@ stop and resolve the conflict before editing `Cargo.toml`. recovered output against the fixture corpus. - [x] (2026-07-13) Milestone 3: implemented OrthoConfig CLI, source, JSON, and process adapters with behavioural and end-to-end validation. +- [x] (2026-07-13) Fixed `!=` lexing on fork branch + `fix-shell-assignment-operator`, validated its 472 unit tests and 98 + doctests, and pinned immutable commit + `8dd35801b75b332c2ac2f995ae398ef8238559fa` through `[patch.crates-io]`. +- [x] (2026-07-13) Passed the complete deterministic makeutil gate set after + applying the patch; the scrutineer independently repeated every gate and + CodeRabbit completed with zero findings across 34 reviewed files. - [ ] Milestone 4: synchronize documentation, run full acceptance, and gather external consumer evidence. @@ -195,20 +202,29 @@ stop and resolve the conflict before editing `Cargo.toml`. reference. - Observation: `makefile-lossless` 0.3.40 documents `!=` as an assignment operator, but parses valid GNU Make `A != printf seven` as recovered rule - fragments with diagnostics and exposes no `VariableDefinition`. Evidence: - the focused `assignment_operators_remain_source_faithful::case_7` test and a - live CLI reproduction both produce zero variable facts; the scrutineer + fragments with diagnostics and exposes no `VariableDefinition`. Evidence: the + focused `assignment_operators_remain_source_faithful::case_7` test and a live + CLI reproduction both produce zero variable facts; the scrutineer independently reproduced the failure. Impact: this triggers the approved upstream stop condition. The exact pin cannot satisfy the source-faithful variable contract without an upstream fix, a separately approved narrow fallback parser, or an explicit scope reduction. +- Observation: the defect was confined to `Lexer::next_token`; the parser and + AST accessors already recognized `!=`, but the operator-token start set + omitted `!`. Evidence: fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` + changes that set and adds lexer and lossless AST regression tests. Impact: + the existing adapter now reports shell assignments source-faithfully without + a makeutil-specific parser fallback or vendored crate. ## Decision log -- Pending decision: resolve the `!=` parser gap before further implementation, - commits, or CodeRabbit review. The available choices are an upstream patch at - the exact pin, approval to change the dependency source/version, or an - explicit schema and behaviour limitation. Date/Author: 2026-07-13 / Codex. +- Decision: patch crates.io resolution to immutable fork commit + `8dd35801b75b332c2ac2f995ae398ef8238559fa` while retaining the approved exact + 0.3.40 version requirement. Rationale: the minimal upstream-shaped fix adds + the missing lexer start character and regression coverage without vendoring, + changing makeutil policy, or exposing a mutable branch reference. Retire the + patch when an adopted upstream release contains the fix. Date/Author: + 2026-07-13 / Codex. - Decision: apply hexagonal architecture only at meaningful volatility and side-effect boundaries. Rationale: domain facts, locations, ordering, and @@ -754,9 +770,10 @@ Planned runtime dependencies are: Before adding each non-exception dependency, verify its current compatible caret version and smallest necessary feature set. Preserve the approved exact -`makefile-lossless = "=0.3.40"` requirement unchanged. Do not add both a direct -`clap` dependency and OrthoConfig's re-exported surface unless the derive/API -contract requires it. +`makefile-lossless = "=0.3.40"` requirement unchanged. Resolve it through the +temporary full-SHA fork patch recorded above until upstream contains the fix. +Do not add both a direct `clap` dependency and OrthoConfig's re-exported +surface unless the derive/API contract requires it. Planned development dependencies are `rstest = "0.26.1"`, `rstest-bdd = "0.6.0-beta3"`, `rstest-bdd-macros = "0.6.0-beta3"`, diff --git a/docs/users-guide.md b/docs/users-guide.md index e285857..9199008 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -32,14 +32,14 @@ cannot supply them. The normative output contract is [`schemas/makeutil.parse.v1.schema.json`](../schemas/makeutil.parse.v1.schema.json). -Byte ranges are zero-based and end-exclusive. Display lines and byte columns are -one-based. - -| Exit code | Meaning | -| --- | --- | -| `0` | Parsing completed and JSON was emitted. | -| `1` | Parsing recovered partial facts with diagnostics and JSON was emitted. | -| `2` | Invocation, input, UTF-8, internal, serialization, or output failed. | +Byte ranges are zero-based and end-exclusive. Display lines and byte columns +are one-based. + +| Exit code | Meaning | +| --------- | ---------------------------------------------------------------------- | +| `0` | Parsing completed and JSON was emitted. | +| `1` | Parsing recovered partial facts with diagnostics and JSON was emitted. | +| `2` | Invocation, input, UTF-8, internal, serialization, or output failed. | _Table 1: `makeutil parse` exit codes._ diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 809ca55..558c238 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -87,6 +87,7 @@ fn recovered_parse_retains_facts_and_diagnostics() { #[case("A :::= four\n", ":::=", "four")] #[case("A += five\n", "+=", "five")] #[case("A ?= six\n", "?=", "six")] +#[case("A != printf seven\n", "!=", "printf seven")] fn assignment_operators_remain_source_faithful( #[case] source: &str, #[case] operator: &str, @@ -102,16 +103,6 @@ fn assignment_operators_remain_source_faithful( assert_eq!(variable.raw_value, raw_value); } -#[rstest] -fn shell_assignment_parser_gap_remains_explicit() { - let report = parse_source(b"A != printf seven\n", "Makefile", &MakefileLosslessParser) - .expect("upstream recovery should still produce a report"); - - assert_eq!(report.parse.status, ParseStatus::Recovered); - assert!(report.variables.is_empty()); - assert!(!report.parse.diagnostics.is_empty()); -} - #[rstest] fn nested_conditions_preserve_outer_to_inner_branches() { let source = b"ifdef OUTER\nifeq ($(A),yes)\nX = one\nelse\nX = two\nendif\nendif\n"; diff --git a/typos.toml b/typos.toml index d94628e..e2341de 100644 --- a/typos.toml +++ b/typos.toml @@ -32,6 +32,7 @@ locale = "en-gb" extend-ignore-re = [ "(?s)```.*?```", "Center \\| Microsoft Learn", + "\\brust-analyzer\\b", "`[^`\\n]+`", ] From 317499b2cee612ae322d035ed76180492419ac74 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 13 Jul 2026 23:34:17 +0100 Subject: [PATCH 04/29] Complete ADR-0001 acceptance evidence Add a consumer-owned schema-v1 deserialization contract and record the final CLI, performance, nesting, include-boundary, and Concordat subprocess acceptance results. Mark implementation complete while explicitly deferring exact terminal-diff CodeRabbit certification to the pull request during the temporary CLI outage. --- .../adr-0001-single-file-gnu-make-parse.md | 157 +++++++++++++----- tests/report_schema.rs | 41 +++++ 2 files changed, 160 insertions(+), 38 deletions(-) diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index bb7a3ce..dbf9d6d 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -4,7 +4,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` must be kept up to date as work proceeds. -Status: APPROVED / IN PROGRESS. +Status: IMPLEMENTATION COMPLETE / AWAITING PR REVIEW. ## Approval @@ -140,8 +140,9 @@ stop and resolve the conflict before editing `Cargo.toml`. - Risk: the Concordat integration criterion is outside this repository. Severity: medium. Likelihood: high. Mitigation: provide a consumer-shaped deserialization fixture and record the external Concordat trial as evidence - required before ADR status moves from Proposed to Accepted; do not fabricate - cross-repository proof. + required before implementation is declared complete; do not fabricate + cross-repository proof. Outcome: the trial passed in the available Concordat + checkout. - Risk: strict lints and code-size limits may encourage premature abstraction. Severity: medium. Likelihood: medium. Mitigation: keep modules cohesive, sweep for equivalent helpers before every extraction, and add a trait only at @@ -161,8 +162,9 @@ stop and resolve the conflict before editing `Cargo.toml`. diagnostics, failure output, and observability before approval. - [x] (2026-07-13) Passed all planning milestone deterministic gates and resolved every actionable concern from three CodeRabbit review rounds. -- [ ] Obtain a clean CodeRabbit follow-up after the service rate limit resets; - the post-fix attempt stopped before analysis and emitted no new findings. +- [x] (2026-07-13) Obtained a clean CodeRabbit follow-up after the service rate + limit reset; the final pre-completion review examined 34 files and reported + zero findings. - [x] (2026-07-13) Obtained explicit approval of this ExecPlan, including the exact parser pin exception and schema/path decisions. - [x] (2026-07-13) Milestone 1: proved upstream contracts and froze the @@ -178,8 +180,24 @@ stop and resolve the conflict before editing `Cargo.toml`. - [x] (2026-07-13) Passed the complete deterministic makeutil gate set after applying the patch; the scrutineer independently repeated every gate and CodeRabbit completed with zero findings across 34 reviewed files. -- [ ] Milestone 4: synchronize documentation, run full acceptance, and gather - external consumer evidence. +- [x] (2026-07-13) Added a consumer-owned schema-v1 deserialization test with + focused red/green and Clippy evidence. +- [x] (2026-07-13) Completed the manual CLI acceptance exercise with path, + recovered, and stdin exit codes `0`, `1`, and `0` respectively. +- [x] (2026-07-13) Measured exact-size 1, 5, and 10 MiB inputs and 256 nested + conditionals in release mode; every run remained inside the elapsed-time and + memory guardrails. +- [x] (2026-07-13) Ran the release binary from the Concordat Python 3.13 + environment, decoded schema v1 without a Rust binding, and found its required + `build`, `lint`, and `test` targets in a complete parse. +- [x] (2026-07-13) Used `strace` to prove that existing literal and dynamic + include paths were reported but never opened. +- [x] (2026-07-13) Milestone 4: synchronized contracts, completed all acceptance + exercises, and passed every deterministic gate under independent scrutineer + validation. +- [ ] Obtain CodeRabbit certification of the exact terminal diff through the + pull request. The user approved deferral from the unavailable CLI review + during CodeRabbit's temporary outage. ## Surprises & discoveries @@ -215,9 +233,34 @@ stop and resolve the conflict before editing `Cargo.toml`. changes that set and adds lexer and lossless AST regression tests. Impact: the existing adapter now reports shell assignments source-faithfully without a makeutil-specific parser fallback or vendored crate. +- Observation: the manual acceptance command named `complete.mk`, but the + committed complete fixture is `all-facts.mk`. Evidence: the fixture corpus + contains `all-facts.mk` and `recovered.mk`; the corrected command produced a + complete schema-v1 report. Impact: the command below now uses the real + fixture path. +- Observation: the consumer-shaped test initially expected an `all` target, + while the representative fixture defines `check`. Evidence: the focused red + run failed with a clear `check` versus `all` diff; changing only the consumer + expectation made the focused test and Clippy pass. Impact: this supplies + honest red/green evidence without changing production behaviour. ## Decision log +- Decision: defer exact terminal-diff CodeRabbit certification to the pull + request after the CLI first rate-limited and then required unavailable + browser authentication during a temporary service outage. Rationale: the + exact diff passed every independent deterministic gate, the immediately + preceding review was clean, and the user explicitly approved waiting for PR + review rather than blocking the commit. Date/Author: 2026-07-13 / User and + Codex. + +- Decision: generate large performance fixtures ephemerally and check their + exact byte lengths with `stat` rather than commit 16 MiB of repetitive test + data. Rationale: fixed `all:` and newline framing around repeated `a` bytes + produces valid, deterministic rule fixtures while keeping the repository + small; the measured command fails before timing if any size differs. Date/ + Author: 2026-07-13 / Codex. + - Decision: patch crates.io resolution to immutable fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` while retaining the approved exact 0.3.40 version requirement. Rationale: the minimal upstream-shaped fix adds @@ -274,11 +317,16 @@ stop and resolve the conflict before editing `Cargo.toml`. ## Outcomes & retrospective -Planning is complete when this draft has passed deterministic documentation -gates, community-of-experts review, scrutineer CodeRabbit review, and is -available in a draft pull request. Implementation remains intentionally -unstarted until approval. During execution, update this section after every -milestone with observed behaviour, remaining gaps, and lessons. +The implementation now exposes the approved single-file parse contract through +a capability-safe CLI and stable schema-v1 JSON. Unit, property, snapshot, BDD, +and end-to-end tests cover complete, recovered, fatal, and inert-source paths. +The forked parser fix restores source-faithful `!=` assignments without a +makeutil-specific fallback. Manual CLI acceptance, release-mode guardrails, and +the Concordat subprocess and include-boundary trials all pass. Independent +scrutineer validation repeated every deterministic gate. The implementation of +ADR-0001's single-file GNU Make parse slice is complete; exact terminal-diff +CodeRabbit certification is deferred to the pull request because the CLI +service became unavailable, as explicitly approved by the user. ## Context and orientation @@ -556,7 +604,8 @@ ownership, port/adapter rules, helper reuse policy, fixtures, snapshots, exact parser upgrade gate, and the test-first workflow. Update `docs/repository-layout.md` for source modules, `schemas/`, features, fixtures, snapshots, and end-to-end tests. Reconcile ADR-0001 with the documentation -style guide and only move its status after required external evidence exists. +style guide and confirm that its Accepted status is supported by current +external evidence. Add a consumer-shaped test that deserializes representative schema-v1 JSON without linking Rust implementation types. Record the command and result for an @@ -637,24 +686,25 @@ The final manual acceptance exercise is: ```shell cargo build --bin makeutil -target/debug/makeutil parse tests/fixtures/makefiles/complete.mk +target/debug/makeutil parse tests/fixtures/makefiles/all-facts.mk target/debug/makeutil parse tests/fixtures/makefiles/recovered.mk printf 'all:\n\t@echo ok\n' | target/debug/makeutil parse --stdin-filename Makefile - ``` -The second and fourth commands must emit one compact schema-v1 JSON line and -exit 0. The third command must emit recovered facts and diagnostics and exit 1. -Capture exit codes explicitly during implementation rather than relying on a -shell pipeline that hides them. - -Generate deterministic 1 MiB, 5 MiB, and 10 MiB valid rule fixtures with a -checked test helper, build release mode, warm each input once, then measure -three runs with `/usr/bin/time -v`. Record median elapsed time and maximum -resident set size in `Artefacts and notes`. Require the 10 MiB median to remain -under two seconds and maximum resident set size under 256 MiB on Linux, and -inspect the three sizes for super-linear growth. Exercise the generated -256-level conditional fixture in three consecutive release-mode measurements -with `/usr/bin/time -v`; require each Linux run to stay under 256 MiB maximum +The first parse command and the stdin pipeline must emit one compact schema-v1 +JSON line and exit 0. The recovered-fixture command must emit recovered facts +and diagnostics and exit 1. Capture exit codes explicitly during implementation +rather than relying on a shell pipeline that hides them. + +Generate deterministic 1 MiB, 5 MiB, and 10 MiB valid rule fixtures with the +ephemeral generator recorded in the Decision log, assert their exact lengths +before measuring, build release mode, warm each input once, then measure three +runs with `/usr/bin/time -v`. Record median elapsed time and maximum resident +set size in `Artefacts and notes`. Require the 10 MiB median to remain under +two seconds and maximum resident set size under 256 MiB on Linux, and inspect +the three sizes for super-linear growth. Exercise the generated 256-level +conditional fixture in three consecutive release-mode measurements with +`/usr/bin/time -v`; require each Linux run to stay under 256 MiB maximum resident set size and prove that iterative traversal does not overflow the stack. On a non-Linux host, record that RSS is not comparable and retain elapsed-time and correctness evidence. @@ -717,9 +767,8 @@ bulk-accept snapshots. Firecrawl research used the authoritative 0.3.40 docs.rs source and tagged upstream repository. It confirmed that the crate exports its GNU Make variant, lossless `Makefile`, parse-result type, ordinary errors, positioned errors, -rules, recipes, variables, includes, conditionals, and Rowan ranges. During -Milestone 1, replace this planning summary with compile-checked signatures and -concise transcripts from the exact dependency. +rules, recipes, variables, includes, conditionals, and Rowan ranges. Milestone +1 compile-checked those mappings against the exact dependency. The Wyvern team independently found no existing abstraction to reuse and recommended the same narrow parser-port boundary. The community-of-experts @@ -729,10 +778,42 @@ offered for approval. The scrutineer recorded passing `git diff --check`, Markdown and spelling, Nixie, Rust formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, nextest, and doctest gates. Three completed CodeRabbit rounds reported 11, 9, -and 7 actionable concerns respectively; all were addressed. A fourth post-fix -attempt was rejected before analysis by a recoverable rate limit with an -estimated 34-minute wait and emitted no findings. This is not represented as a -clean review result and should be retried before implementation begins. +and 7 actionable concerns respectively; all were addressed. A later +pre-completion review completed successfully across 34 files with zero findings. + +The final manual CLI exercise produced `complete=0`, `recovered=1`, and +`stdin=0`. Every command wrote one schema-v1 JSON document, no command wrote to +standard error, and the reports classified their parse status as expected. + +The include-boundary exercise created existing `literal.mk` and `dynamic.mk` +files next to the input, traced `openat` and `openat2`, and asserted that +neither include path occurred in the syscall log. The binary reported both +includes in a complete parse; the result was `include_opened=false`. + +Release-mode `/usr/bin/time -v` evidence after one warm-up per input was: + +| Input | Elapsed runs | Maximum RSS runs (KiB) | +| ----------------------- | ---------------------- | ---------------------- | +| 1 MiB | 0.01 s, 0.01 s, 0.01 s | 7,168; 7,232; 7,316 | +| 5 MiB | 0.06 s, 0.06 s, 0.06 s | 23,608; 23,624; 23,708 | +| 10 MiB | 0.12 s, 0.12 s, 0.12 s | 44,100; 44,380; 44,080 | +| 256 nested conditionals | 0.01 s, 0.01 s, 0.01 s | 8,808; 8,672; 8,852 | + +The large inputs were exact-size single rules generated from a fixed `all:` +header, repeated `a` bytes, and a newline. A `stat` assertion checked every +length before timing. The nested input contained 256 deterministic `ifdef`/ +`endif` pairs around one rule. Growth was sub-linear across the measured sizes, +the 10 MiB median was 0.12 seconds, and all resident-set measurements were +below 256 MiB. + +From `/data/leynos/Projects/concordat`, a Python 3.13 subprocess invoked the +release binary against Concordat's Makefile, decoded JSON with the standard +library, asserted schema version 1 and complete status, and found `build`, +`lint`, and `test`. The successful summary was: + +```plaintext +{"schema_version":1,"status":"complete","required_targets":["build","lint","test"],"language_binding":false} +``` ## Interfaces and dependencies @@ -741,13 +822,13 @@ match `docs/design.md` and the JSON Schema: ```rust pub trait MakefileParser { - fn parse(&self, source: &str) -> Result, ParseEngineError>; + fn parse(&self, source: &str) -> Result; } -pub fn parse_source( - parser: &P, - logical_path: &Utf8Path, +pub fn parse_source( source: &[u8], + logical_path: &str, + parser: &impl MakefileParser, ) -> Result; ``` diff --git a/tests/report_schema.rs b/tests/report_schema.rs index 7160873..36939ad 100644 --- a/tests/report_schema.rs +++ b/tests/report_schema.rs @@ -1,8 +1,26 @@ //! JSON Schema and snapshot tests for the stable report contract. use makeutil::{adapters::MakefileLosslessParser, parse_source}; +use pretty_assertions::assert_eq; use rstest::rstest; +#[derive(Debug, serde::Deserialize)] +struct ConsumerReport { + schema_version: u64, + parse: ConsumerParse, + rules: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct ConsumerParse { + status: String, +} + +#[derive(Debug, serde::Deserialize)] +struct ConsumerRule { + targets: Vec, +} + fn schema() -> Result { serde_json::from_str(include_str!("../schemas/makeutil.parse.v1.schema.json")) } @@ -35,6 +53,29 @@ fn malformed_near_miss_is_rejected() -> Result<(), Box> { } } +#[rstest] +fn independent_consumer_deserializes_schema_v1() -> Result<(), Box> { + let report = parse_source( + include_bytes!("fixtures/makefiles/all-facts.mk"), + "Makefile", + &MakefileLosslessParser, + )?; + let document = serde_json::to_vec(&report)?; + let consumer: ConsumerReport = serde_json::from_slice(&document)?; + + assert_eq!(consumer.schema_version, 1); + assert_eq!(consumer.parse.status, "complete"); + assert_eq!( + consumer + .rules + .first() + .and_then(|rule| rule.targets.first()) + .map(String::as_str), + Some("check") + ); + Ok(()) +} + #[rstest] fn all_fact_variants_have_stable_json() -> Result<(), Box> { let report = parse_source( From 2281d2dda343631b0f800d2d332a7ea1b6ca4d8a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 00:21:27 +0100 Subject: [PATCH 05/29] Address ADR-0001 review findings Preserve source-faithful variable values and recognize every recipe modifier ordering at the parser adapter boundary. Share a closed conditional-kind type across domain and port contracts, and keep CLI orchestration focused through small private helpers. Expand regression coverage and synchronize the schema snapshot, architecture guidance, imported guides, README, and ExecPlan review evidence. --- README.md | 24 +++- docs/design.md | 32 +++-- docs/developers-guide.md | 18 +++ .../adr-0001-single-file-gnu-make-parse.md | 41 +++++- docs/ortho-config-users-guide.md | 8 +- docs/rstest-bdd-users-guide.md | 32 +---- src/adapters/cli.rs | 54 +++++--- src/adapters/makefile.rs | 73 +++++++++-- src/domain/mod.rs | 28 +++- src/ports.rs | 10 +- tests/cli_e2e.rs | 33 ++--- tests/domain_contract.rs | 95 +++++++++++++- tests/fixtures/makefiles/all-facts.mk | 5 + ...a__all_fact_variants_have_stable_json.snap | 120 +++++++++++++----- typos.toml | 3 +- 15 files changed, 443 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index bf62c59..6a303de 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,35 @@ [![Ask DeepWiki](https://deepwiki.com/badge.svg)]( https://deepwiki.com/leynos/makeutil) -This is a generated project using [Copier](https://copier.readthedocs.io/). +*Parse one GNU Makefile into deterministic, source-faithful JSON.* + +`makeutil` reports rules, recipes, variables, includes, conditionals, source +locations, and recoverable diagnostics without evaluating the Makefile or +following its includes. + +______________________________________________________________________ + +## Quick start + +Parse the repository's representative fixture: + +```shell +cargo run -- parse tests/fixtures/makefiles/all-facts.mk +``` + +The command writes one compact JSON document to standard output. + +______________________________________________________________________ + +## Development This crate requires the Polonius borrow-checking analysis (`-Zpolonius=next`) on the pinned nightly toolchain. The flag is configured in `.cargo/config.toml`; see [Polonius migration](docs/polonius.md) for the project's borrow-checking conventions and audit inventory. +______________________________________________________________________ + ## Documentation - [Documentation contents](docs/contents.md) diff --git a/docs/design.md b/docs/design.md index 9489616..8703d67 100644 --- a/docs/design.md +++ b/docs/design.md @@ -348,14 +348,14 @@ The direct `rowan` dependency exists only to bring its `AstNode` trait into the parser adapter for upstream syntax ranges. `data-encoding` owns lower-case digest rendering. Neither dependency expands the stable public contract. -| Component | Responsibility | -| -------------- | ---------------------------------------------------------------------------------------------- | -| CLI front end | Parse the command and validate that exactly one source was supplied. | -| Source reader | Read one path or stdin into bytes without interpreting or normalizing it. | -| Parser adapter | Invoke `makefile-lossless` and return ordered owned observations and diagnostics. | -| Fact collector | Flatten observations, attach conditions and locations, assign ordinals, and calculate SHA-256. | -| Location index | Convert byte offsets into one-based line and byte-column positions. | -| JSON reporter | Serialize schema version 1 deterministically to standard output. | +| Component | Responsibility | +| -------------- | --------------------------------------------------------------------------------- | +| CLI front end | Parse the command and validate that exactly one source was supplied. | +| Source reader | Read one path or stdin into bytes without interpreting or normalizing it. | +| Parser adapter | Invoke `makefile-lossless` and return ordered owned observations and diagnostics. | +| Fact collector | Flatten observations, attach conditions and locations, and assign ordinals. | +| Location index | Convert byte offsets into one-based line and byte-column positions. | +| JSON reporter | Serialize schema version 1 deterministically to standard output. | The package may expose a Rust library internally for unit tests, but only the CLI and JSON schema form a supported integration contract in the first release. @@ -368,6 +368,9 @@ diagnostics. The `makefile-lossless` adapter implements the port and proves its own complete-tree round trip; it never returns Rowan nodes, upstream errors, or rendered CST bytes through the port. +The application service calculates SHA-256 over the exact input bytes while +`parse_source` constructs `SourceIdentity`. + The composition root parses the CLI, invokes the source reader, calls the application service, and hands the completed report to the JSON reporter. Edge adapters do not call each other. Source and reporter traits are introduced only @@ -436,16 +439,17 @@ for this failure class. Fatal stderr diagnostics have one stable first line: ```plaintext -makeutil[]: : +makeutil: : ``` Operation identifiers distinguish `cli`, `source-open`, `source-read`, `source-utf8`, `parse-internal`, `json-serialize`, and `stdout-write`. Normal -success and recovered parsing emit no stderr. Backtraces and cause chains are -not printed by default. The binary may install one tracing subscriber, but it -must never write tracing events to stdout; the library installs no subscriber. -Source contents and unbounded raw paths are not tracing fields. This one-shot -CLI emits no metrics in the first slice. +success and recovered parsing emit no stderr. The detail includes the logical +path for `source-open` and `source-read` failures. Backtraces and cause chains +are not printed by default. The binary may install one tracing subscriber, but +it must never write tracing events to stdout; the library installs no +subscriber. Source contents and unbounded raw paths are not tracing fields. +This one-shot CLI emits no metrics in the first slice. ## 11. Verification strategy diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ed774a3..b0d4741 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,6 +19,24 @@ external capability requires another port. CLI path and stdin filename values must continue to use OrthoConfig's explicit `ArgMatches` extraction, without file or environment layers. +`ConditionKind` is the shared, closed domain and parser-port representation for +`ifdef`, `ifndef`, `ifeq`, and `ifneq`. The parser adapter is its only producer; +`SyntaxObservation` and report types are its permitted consumers. Extend the +enum only when the supported GNU Make contract adds another directive, and do +not pass upstream strings beyond the adapter. + +The makefile adapter privately scans leading recipe modifiers. This scanner +exists because the upstream API has no always-execute accessor and its silent +and ignore-error accessors are sensitive to modifier order. It may be called +only while translating an upstream recipe into a `RecipeObservation`; it is not +a general Make lexer, domain helper, or reusable port. + +The CLI adapter's private extraction, report-production, and report-emission +helpers divide its orchestration into focused steps. They may be called only by +the CLI adapter and must remain ordinary private functions. Promote one to a +port only if a distinct external capability needs the same contract, not merely +to share implementation detail or simplify a test. + The exact 0.3.40 parser requirement is temporarily patched to immutable fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa`, which adds `!=` lexer support. Keep the commit pin reproducible. When upgrading to an upstream diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index dbf9d6d..31ac29d 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -195,6 +195,14 @@ stop and resolve the conflict before editing `Cargo.toml`. - [x] (2026-07-13) Milestone 4: synchronized contracts, completed all acceptance exercises, and passed every deterministic gate under independent scrutineer validation. +- [x] (2026-07-14) Reviewed the terminal diff and applied valid fixes for + trailing variable whitespace, recipe-modifier ordering, closed conditional + kinds, focused CLI helpers, and documentation drift. The focused whitespace + and modifier-order tests supplied red evidence. The complete deterministic + gate set then passed, and the scrutineer independently confirmed 45 of 45 + tests, two passing doctests with one intentionally ignored, and clean + formatting, Polonius type-checking, lint, documentation, diagram, and diff + checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. @@ -243,6 +251,19 @@ stop and resolve the conflict before editing `Cargo.toml`. run failed with a clear `check` versus `all` diff; changing only the consumer expectation made the focused test and Clippy pass. Impact: this supplies honest red/green evidence without changing production behaviour. +- Observation: focused review tests showed that trimming a variable value lost + source-faithful trailing whitespace and that upstream recipe accessors did + not recognize every ordering of leading `@`, `-`, and `+` modifiers. Evidence: + `variable_values_preserve_trailing_whitespace` and + `recipe_modifier_order_is_semantic` failed before their narrow adapter fixes. + Impact: raw values now remain untrimmed, and one adapter-private scanner + derives all three recipe flags without widening the parser port. +- Observation: a review finding claimed the file reader did not use a + capability-oriented boundary, but `read_path` already used + `cap_std::fs_utf8::File::open_ambient` with explicit ambient authority. + Evidence: `src/adapters/source.rs` owns that call and maps its open and read + failures into `SourceReadError`. Impact: the finding was stale and required + no source-reader change. ## Decision log @@ -314,6 +335,15 @@ stop and resolve the conflict before editing `Cargo.toml`. Rationale: the process can prevent serialization failures from writing JSON, but cannot retract accepted bytes after a broken pipe or partial write. Date/Author: 2026-07-13 / Logisphere-reviewed Codex planning team. +- Decision: keep review-driven helpers at their narrowest validated ownership + boundary. `ConditionKind` is the closed domain/port representation consumed + by observations and reports; the makefile adapter alone owns the private + leading-recipe-modifier scanner; and CLI extraction, production, and emission + helpers remain private to the CLI adapter. Rationale: these boundaries remove + stringly typed drift and order-sensitive defects without creating reusable + ports for implementation details. Permitted call sites and reuse policy are + recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Wyvern + review team. ## Outcomes & retrospective @@ -868,8 +898,9 @@ Decision log. ## Revision note -Revised 2026-07-13 after Wyvern, Logisphere, and CodeRabbit review: freeze -path, range, schema, parser-port, failure-output, CLI merge, security, -performance, and dependency decisions; import and correct the OrthoConfig 0.8.0 -guide; and record deterministic and rate-limit evidence. No feature -implementation has begun. +Initially revised 2026-07-13 after Wyvern, Logisphere, and CodeRabbit review to +freeze path, range, schema, parser-port, failure-output, CLI merge, security, +performance, and dependency decisions and to import and correct the OrthoConfig +0.8.0 guide. Implementation completed on 2026-07-13 with deterministic gates, +manual acceptance, performance measurements, and external Concordat and +include-boundary evidence recorded above. Pull request review remains pending. diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 7899cca..237cb1d 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -48,7 +48,8 @@ values from multiple sources. The core features are: The workspace bundles an executable Hello World example under `examples/hello_world`. It layers defaults, environment variables, and CLI -flags via the derive macro; see its [README](../examples/hello_world/README.md) +flags via the derive macro; see its +[README](https://github.com/leynos/ortho-config/blob/main/examples/hello_world/README.md) for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven Development) scenarios that validate behaviour end-to-end. @@ -1006,8 +1007,9 @@ subcommand execution. Each subcommand struct implements a trait defining the action to perform. An enum of subcommands is annotated with `#[clap_dispatch(fn run(...))]`, and the `load_and_merge_subcommand_for` function can be called on each variant before dispatching. See the -`Subcommand Configuration` section of the `OrthoConfig` [README](../README.md) -for a complete example. +`Subcommand Configuration` section of the `OrthoConfig` +[README](https://github.com/leynos/ortho-config/blob/main/README.md) for a +complete example. ## Error handling diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index c32902a..fb5d2fe 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1018,10 +1018,8 @@ types such as `HarnessAdapter` or `ScenarioRunRequest`. `TokioHarness` can then be used directly in scenarios. For this first-party adapter, the macro infers `TokioAttributePolicy` from the canonical harness -path when `attributes = ...` is omitted: +path when `attributes = ...` is omitted. -```rust,no_run -# use rstest_bdd_macros::scenario; ### Using the GPUI harness The `rstest-bdd-harness-gpui` crate provides Graphical Processing User @@ -1029,7 +1027,8 @@ Interface (GPUI) integration for harness delegation and test attributes. Add it as a dev-dependency: ```toml -[dev-dependencies] rstest-bdd-harness-gpui = "0.6.0-beta3" +[dev-dependencies] +rstest-bdd-harness-gpui = "0.6.0-beta3" ``` A direct `rstest-bdd-harness` dependency is not required when using @@ -1039,10 +1038,8 @@ types such as `HarnessAdapter` or `ScenarioRunRequest`. `GpuiHarness` can then be used directly in scenarios. For this first-party adapter, the macro infers `GpuiAttributePolicy` from the canonical harness path -when `attributes = ...` is omitted: +when `attributes = ...` is omitted. -```rust,no_run -# use rstest_bdd_macros::scenario; #### GPUI panic diagnostics carry scenario context When a step running under `GpuiHarness` panics, the harness prepends the @@ -2296,26 +2293,7 @@ fn collect_active(rows: Rows) -> Result, DataTableError> { `Rows` propagates [`DataTableError`] variants unchanged, making it easy to surface context when something goes wrong. Matching on the error value enables -inspection of the row and column that triggered the failure: - -```rust,no_run -# use rstest_bdd::datatable::{DataTableError, Rows}; -# use rstest_bdd_macros::DataTableRow; -# -# #[derive(Debug, PartialEq, Eq, DataTableRow)] -# struct UserRow { -# name: String, -# #[datatable(truthy)] -# active: bool, -# } -``` - -The selection function preserves the caller-supplied order, so applications can -pass a list of preferred locales. The helper resolves to the best available -translation and continues to fall back to English when a requested locale is -not shipped with the crate. Procedural macro diagnostics remain in English so -compile-time output stays deterministic regardless of the host machine’s -language settings. +inspection of the row and column that triggered the failure. ## Limitations and roadmap diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 9accdd9..3f44628 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -11,7 +11,10 @@ use super::{ MakefileLosslessParser, source::{read_path, read_stdin}, }; -use crate::{domain::ParseStatus, parse_source}; +use crate::{ + domain::{ParseReport, ParseStatus}, + parse_source, +}; /// Root command line for `makeutil`. #[derive(Debug, Parser)] @@ -111,27 +114,44 @@ fn run_parse( "parse subcommand matches were absent", ); }; - // This OrthoConfig extraction intentionally reads only explicit ArgMatches; - // path identity must never come from environment or configuration files. - let explicit = match parsed_arguments.extract_user_provided(parse_matches) { - Ok(value) => value, - Err(error) => return fatal(streams.stderr, "cli", &error.to_string()), + let explicit_arguments = match extract_explicit_arguments(parsed_arguments, parse_matches) { + Ok(arguments) => arguments, + Err(error) => return fatal(streams.stderr, "cli", &error), }; - let explicit_arguments: ParseArgs = match serde_json::from_value(explicit) { - Ok(explicit_arguments) => explicit_arguments, - Err(error) => return fatal(streams.stderr, "cli", &error.to_string()), - }; - let (bytes, logical_path) = match read_input(explicit_arguments, streams) { - Ok(input) => input, + let report = match produce_report(explicit_arguments, streams) { + Ok(report) => report, Err(outcome) => return outcome, }; - let report = match parse_source(&bytes, &logical_path, &MakefileLosslessParser) { - Ok(report) => report, + emit_report(&report, streams) +} + +fn extract_explicit_arguments( + parsed_arguments: &ParseArgs, + parse_matches: &clap::ArgMatches, +) -> Result { + // This OrthoConfig extraction intentionally reads only explicit ArgMatches; + // path identity must never come from environment or configuration files. + let explicit = parsed_arguments + .extract_user_provided(parse_matches) + .map_err(|error| error.to_string())?; + serde_json::from_value(explicit).map_err(|error| error.to_string()) +} + +fn produce_report( + arguments: ParseArgs, + streams: &mut Streams<'_>, +) -> Result { + let (bytes, logical_path) = read_input(arguments, streams)?; + match parse_source(&bytes, &logical_path, &MakefileLosslessParser) { + Ok(report) => Ok(report), Err(crate::ParseApplicationError::InvalidUtf8(error)) => { - return fatal(streams.stderr, "source-utf8", &error.to_string()); + Err(fatal(streams.stderr, "source-utf8", &error.to_string())) } - Err(error) => return fatal(streams.stderr, "parse-internal", &error.to_string()), - }; + Err(error) => Err(fatal(streams.stderr, "parse-internal", &error.to_string())), + } +} + +fn emit_report(report: &ParseReport, streams: &mut Streams<'_>) -> ProcessOutcome { let mut document = match serde_json::to_vec(&report) { Ok(document) => document, Err(error) => return fatal(streams.stderr, "json-serialize", &error.to_string()), diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index c4579bc..590542d 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -4,7 +4,7 @@ use makefile_lossless::{Conditional, Makefile, MakefileItem, Parse, SyntaxKind}; use rowan::ast::AstNode as _; use crate::{ - domain::{ConditionBranch, SourceSpan}, + domain::{ConditionBranch, ConditionKind, SourceSpan}, ports::{ ConditionObservation, MakefileParser, @@ -47,11 +47,11 @@ fn collect_items( .recipe_nodes() .map(|recipe| { let text = recipe.text(); + let modifiers = recipe_modifiers(&text); Ok(RecipeObservation { - silent: recipe.is_silent(), - ignore_errors: recipe.is_ignore_errors(), - always_execute: text.trim_start_matches(['@', '-']).starts_with('+') - || text.starts_with('+'), + silent: modifiers.silent, + ignore_errors: modifiers.ignore_errors, + always_execute: modifiers.always_execute, text, span: span(recipe.text_range(), source_length)?, }) @@ -72,7 +72,7 @@ fn collect_items( field: "variable-name", })?, operator: variable.assignment_operator().unwrap_or_default(), - raw_value: variable.raw_value().unwrap_or_default().trim().to_owned(), + raw_value: variable.raw_value().unwrap_or_default(), exported: variable.is_export(), overridden: variable.is_override(), define_block: variable.is_define(), @@ -112,15 +112,16 @@ fn collect_conditional( .ok_or(ParserPortError::MissingField { field: "conditional-opening", })?; - let kind = conditional + let raw_kind = conditional .conditional_type() .ok_or(ParserPortError::MissingField { field: "conditional-kind", })?; + let kind = condition_kind(&raw_kind)?; let expression = conditional.condition().unwrap_or_default(); let mut if_conditions = outer.to_vec(); if_conditions.push(ConditionObservation { - kind: kind.clone(), + kind, expression: expression.clone(), branch: ConditionBranch::If, span: span(opening.text_range(), source_length)?, @@ -177,11 +178,44 @@ fn collect_else_branch( } struct ElseBranch { - kind: String, + kind: ConditionKind, expression: String, source_length: usize, } +#[derive(Debug, Default)] +struct RecipeModifiers { + silent: bool, + ignore_errors: bool, + always_execute: bool, +} + +fn recipe_modifiers(text: &str) -> RecipeModifiers { + text.chars() + .take_while(|character| matches!(character, '@' | '-' | '+')) + .fold(RecipeModifiers::default(), |mut modifiers, character| { + match character { + '@' => modifiers.silent = true, + '-' => modifiers.ignore_errors = true, + '+' => modifiers.always_execute = true, + _ => {} + } + modifiers + }) +} + +fn condition_kind(kind: &str) -> Result { + match kind { + "ifdef" => Ok(ConditionKind::Ifdef), + "ifndef" => Ok(ConditionKind::Ifndef), + "ifeq" => Ok(ConditionKind::Ifeq), + "ifneq" => Ok(ConditionKind::Ifneq), + _ => Err(ParserPortError::UnsupportedConditionKind { + kind: kind.to_owned(), + }), + } +} + fn collect_diagnostics( parsed: &Parse, source: &str, @@ -227,3 +261,24 @@ fn span( ) -> Result { SourceSpan::new(range.start().into(), range.end().into(), source_length).map_err(Into::into) } + +#[cfg(test)] +mod tests { + //! Adapter invariant tests for unsupported upstream syntax. + + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::condition_kind; + use crate::ports::ParserPortError; + + #[rstest] + fn unknown_condition_kind_is_rejected() { + assert_eq!( + condition_kind("ifunknown"), + Err(ParserPortError::UnsupportedConditionKind { + kind: "ifunknown".to_owned(), + }) + ); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 2b0bf45..534022d 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -77,7 +77,7 @@ pub struct ParseSummary { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ConditionContext { /// GNU Make conditional keyword. - pub kind: String, + pub kind: ConditionKind, /// Unexpanded condition expression. pub expression: String, /// Branch containing the fact. @@ -86,6 +86,32 @@ pub struct ConditionContext { pub location: SourceLocation, } +/// GNU Make conditional directive kind. +/// +/// # Examples +/// +/// ``` +/// use makeutil::domain::ConditionKind; +/// +/// assert_eq!( +/// serde_json::to_string(&ConditionKind::Ifndef)?, +/// r#""ifndef""# +/// ); +/// # Ok::<(), serde_json::Error>(()) +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ConditionKind { + /// Variable is defined. + Ifdef, + /// Variable is not defined. + Ifndef, + /// Expressions are equal. + Ifeq, + /// Expressions are not equal. + Ifneq, +} + /// Branch of a conditional. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] diff --git a/src/ports.rs b/src/ports.rs index 73762e8..756b400 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -2,7 +2,7 @@ use thiserror::Error; -use crate::domain::{ConditionBranch, LocationError, SourceSpan}; +use crate::domain::{ConditionBranch, ConditionKind, LocationError, SourceSpan}; /// Parser output expressed only in makeutil-owned observations. #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,7 +15,7 @@ pub struct ParserOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConditionObservation { /// GNU Make conditional keyword. - pub kind: String, + pub kind: ConditionKind, /// Unexpanded expression. pub expression: String, /// Branch containing the fact. @@ -113,6 +113,12 @@ pub enum ParserPortError { /// Stable semantic field name. field: &'static str, }, + /// An upstream conditional keyword was outside the supported GNU Make set. + #[error("unsupported conditional kind {kind}")] + UnsupportedConditionKind { + /// Upstream keyword that could not be represented by the domain enum. + kind: String, + }, } /// Parses source without exposing upstream parser types. diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs index 63ed177..f75adff 100644 --- a/tests/cli_e2e.rs +++ b/tests/cli_e2e.rs @@ -3,12 +3,17 @@ use std::io::Write as _; use assert_cmd::Command; -use rstest::rstest; +use rstest::{fixture, rstest}; + +#[fixture] +fn makeutil_command() -> Command { + let binary = assert_cmd::cargo::cargo_bin!("makeutil"); + Command::new(binary) +} #[rstest] -fn complete_path_emits_one_json_document() { - let output = Command::cargo_bin("makeutil") - .expect("binary should build") +fn complete_path_emits_one_json_document(mut makeutil_command: Command) { + let output = makeutil_command .args(["parse", "tests/fixtures/makefiles/all-facts.mk"]) .output() .expect("binary should run"); @@ -27,9 +32,8 @@ fn complete_path_emits_one_json_document() { } #[rstest] -fn recovered_path_exits_one_with_json() { - let output = Command::cargo_bin("makeutil") - .expect("binary should build") +fn recovered_path_exits_one_with_json(mut makeutil_command: Command) { + let output = makeutil_command .args(["parse", "tests/fixtures/makefiles/recovered.mk"]) .output() .expect("binary should run"); @@ -49,9 +53,8 @@ fn recovered_path_exits_one_with_json() { #[rstest] #[case(&["parse", "-"][..])] #[case(&["parse"][..])] -fn invalid_invocation_exits_two(#[case] arguments: &[&str]) { - let output = Command::cargo_bin("makeutil") - .expect("binary should build") +fn invalid_invocation_exits_two(mut makeutil_command: Command, #[case] arguments: &[&str]) { + let output = makeutil_command .args(arguments) .output() .expect("binary should run"); @@ -60,7 +63,7 @@ fn invalid_invocation_exits_two(#[case] arguments: &[&str]) { } #[rstest] -fn hostile_source_is_inert() { +fn hostile_source_is_inert(mut makeutil_command: Command) { let temporary = tempfile::tempdir().expect("temporary directory should exist"); let sentinel = temporary.path().join("sentinel"); let source = format!( @@ -68,8 +71,7 @@ fn hostile_source_is_inert() { sentinel.display(), sentinel.display() ); - let mut command = Command::cargo_bin("makeutil").expect("binary should build"); - let output = command + let output = makeutil_command .args(["parse", "--stdin-filename", "Makefile", "-"]) .write_stdin(source) .output() @@ -79,7 +81,7 @@ fn hostile_source_is_inert() { } #[rstest] -fn invalid_utf8_is_a_fatal_source_error() { +fn invalid_utf8_is_a_fatal_source_error(mut makeutil_command: Command) { let mut source = tempfile::NamedTempFile::new().expect("temporary file should exist"); source .write_all(&[0xff]) @@ -88,8 +90,7 @@ fn invalid_utf8_is_a_fatal_source_error() { .path() .to_str() .expect("temporary path should be UTF-8"); - let output = Command::cargo_bin("makeutil") - .expect("binary should build") + let output = makeutil_command .args(["parse", path]) .output() .expect("binary should run"); diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 558c238..9c9a122 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -2,7 +2,7 @@ use makeutil::{ adapters::MakefileLosslessParser, - domain::{LocationIndex, ParseStatus, SourceSpan}, + domain::{ConditionKind, LocationIndex, ParseStatus, SourceSpan}, parse_source, }; use pretty_assertions::assert_eq; @@ -41,14 +41,39 @@ fn application_builds_ordered_facts() { assert_eq!(report.parse.status, ParseStatus::Complete); assert_eq!(report.variables.first().map(|fact| fact.ordinal), Some(0)); - assert_eq!(report.includes.first().map(|fact| fact.ordinal), Some(1)); + assert_eq!(report.variables.len(), 4); + assert_eq!( + report + .variables + .iter() + .find(|fact| fact.name == "RELEASE") + .map(|fact| fact.exported), + Some(true) + ); + assert_eq!( + report + .variables + .iter() + .find(|fact| fact.name == "TOOL") + .map(|fact| fact.overridden), + Some(true) + ); + assert_eq!( + report + .variables + .iter() + .find(|fact| fact.name == "SCRIPT") + .map(|fact| fact.define_block), + Some(true) + ); + assert_eq!(report.includes.first().map(|fact| fact.ordinal), Some(4)); assert_eq!( report .rules .iter() .map(|fact| fact.ordinal) .collect::>(), - [2, 3] + [5, 6] ); assert_eq!( report.rules.first().map(|fact| fact.double_colon), @@ -103,6 +128,70 @@ fn assignment_operators_remain_source_faithful( assert_eq!(variable.raw_value, raw_value); } +#[rstest] +#[case("A = value \n", "value ")] +#[case("A = value\t \n", "value\t ")] +fn variable_values_preserve_trailing_whitespace(#[case] source: &str, #[case] raw_value: &str) { + let report = parse_source(source.as_bytes(), "Makefile", &MakefileLosslessParser) + .expect("assignment should parse"); + let variable = report + .variables + .first() + .expect("one variable should be reported"); + assert_eq!(variable.raw_value, raw_value); +} + +#[rstest] +#[case("@-+echo ok", true, true, true)] +#[case("@+-echo ok", true, true, true)] +#[case("-@+echo ok", true, true, true)] +#[case("-+@echo ok", true, true, true)] +#[case("+@-echo ok", true, true, true)] +#[case("+-@echo ok", true, true, true)] +#[case("echo + later", false, false, false)] +fn recipe_modifier_order_is_semantic( + #[case] recipe: &str, + #[case] silent: bool, + #[case] ignore_errors: bool, + #[case] always_execute: bool, +) { + let source = format!("all:\n\t{recipe}\n"); + let report = parse_source(source.as_bytes(), "Makefile", &MakefileLosslessParser) + .expect("recipe should parse"); + let parsed_recipe = report + .rules + .first() + .and_then(|rule| rule.recipes.first()) + .expect("one recipe should be reported"); + assert_eq!(parsed_recipe.silent, silent); + assert_eq!(parsed_recipe.ignore_errors, ignore_errors); + assert_eq!(parsed_recipe.always_execute, always_execute); +} + +#[rstest] +#[case("ifdef FLAG\nX = one\nendif\n", ConditionKind::Ifdef, "ifdef")] +#[case("ifndef FLAG\nX = one\nendif\n", ConditionKind::Ifndef, "ifndef")] +#[case("ifeq ($(A),yes)\nX = one\nendif\n", ConditionKind::Ifeq, "ifeq")] +#[case("ifneq ($(A),yes)\nX = one\nendif\n", ConditionKind::Ifneq, "ifneq")] +fn condition_kinds_use_the_closed_domain_type( + #[case] source: &str, + #[case] kind: ConditionKind, + #[case] serialized_kind: &str, +) { + let report = parse_source(source.as_bytes(), "Makefile", &MakefileLosslessParser) + .expect("conditional should parse"); + let condition = report + .variables + .first() + .and_then(|variable| variable.conditions.first()) + .expect("one condition should be reported"); + assert_eq!(condition.kind, kind); + assert_eq!( + serde_json::to_value(kind).expect("condition kind should serialize"), + serialized_kind + ); +} + #[rstest] fn nested_conditions_preserve_outer_to_inner_branches() { let source = b"ifdef OUTER\nifeq ($(A),yes)\nX = one\nelse\nX = two\nendif\nendif\n"; diff --git a/tests/fixtures/makefiles/all-facts.mk b/tests/fixtures/makefiles/all-facts.mk index d2bb22f..ee819cc 100644 --- a/tests/fixtures/makefiles/all-facts.mk +++ b/tests/fixtures/makefiles/all-facts.mk @@ -1,4 +1,9 @@ MODE ?= debug +export RELEASE = yes +override TOOL := cargo +define SCRIPT +echo configured +endef include $(CONFIG_DIR)/common.mk ifdef CI diff --git a/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap b/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap index a4da909..bbdf779 100644 --- a/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap +++ b/tests/snapshots/report_schema__all_fact_variants_have_stable_json.snap @@ -12,8 +12,8 @@ expression: report }, "source": { "path": "Makefile", - "sha256": "4ad9a5a5d6a76c84f0aa2b25b174fd3f49a4046718bf30db1d7fd39ee8a98b04", - "byte_length": 123 + "sha256": "1f61a290113f6dbf9b5a3b2d2d62e3e55e25ef3cb891453cea9e676299079280", + "byte_length": 203 }, "parse": { "status": "complete", @@ -21,7 +21,7 @@ expression: report }, "rules": [ { - "ordinal": 2, + "ordinal": 5, "targets": [ "check" ], @@ -35,11 +35,11 @@ expression: report "expression": "CI", "branch": "if", "location": { - "start_byte": 47, - "end_byte": 56, - "start_line": 4, + "start_byte": 127, + "end_byte": 136, + "start_line": 9, "start_column": 1, - "end_line": 5, + "end_line": 10, "end_column": 1 } } @@ -52,26 +52,26 @@ expression: report "ignore_errors": true, "always_execute": true, "location": { - "start_byte": 72, - "end_byte": 87, - "start_line": 6, + "start_byte": 152, + "end_byte": 167, + "start_line": 11, "start_column": 1, - "end_line": 7, + "end_line": 12, "end_column": 1 } } ], "location": { - "start_byte": 56, - "end_byte": 87, - "start_line": 5, + "start_byte": 136, + "end_byte": 167, + "start_line": 10, "start_column": 1, - "end_line": 7, + "end_line": 12, "end_column": 1 } }, { - "ordinal": 3, + "ordinal": 6, "targets": [ "check" ], @@ -85,11 +85,11 @@ expression: report "expression": "CI", "branch": "else", "location": { - "start_byte": 87, - "end_byte": 91, - "start_line": 7, + "start_byte": 167, + "end_byte": 171, + "start_line": 12, "start_column": 1, - "end_line": 7, + "end_line": 12, "end_column": 5 } } @@ -102,21 +102,21 @@ expression: report "ignore_errors": false, "always_execute": false, "location": { - "start_byte": 105, - "end_byte": 117, - "start_line": 9, + "start_byte": 185, + "end_byte": 197, + "start_line": 14, "start_column": 1, - "end_line": 10, + "end_line": 15, "end_column": 1 } } ], "location": { - "start_byte": 92, - "end_byte": 117, - "start_line": 8, + "start_byte": 172, + "end_byte": 197, + "start_line": 13, "start_column": 1, - "end_line": 10, + "end_line": 15, "end_column": 1 } } @@ -139,21 +139,75 @@ expression: report "end_line": 2, "end_column": 1 } + }, + { + "ordinal": 1, + "name": "RELEASE", + "operator": "=", + "raw_value": "yes", + "exported": true, + "overridden": false, + "define_block": false, + "conditions": [], + "location": { + "start_byte": 14, + "end_byte": 35, + "start_line": 2, + "start_column": 1, + "end_line": 3, + "end_column": 1 + } + }, + { + "ordinal": 2, + "name": "TOOL", + "operator": ":=", + "raw_value": "cargo", + "exported": false, + "overridden": true, + "define_block": false, + "conditions": [], + "location": { + "start_byte": 35, + "end_byte": 58, + "start_line": 3, + "start_column": 1, + "end_line": 4, + "end_column": 1 + } + }, + { + "ordinal": 3, + "name": "SCRIPT", + "operator": "", + "raw_value": "echo configured\n", + "exported": false, + "overridden": false, + "define_block": true, + "conditions": [], + "location": { + "start_byte": 58, + "end_byte": 94, + "start_line": 4, + "start_column": 1, + "end_line": 7, + "end_column": 1 + } } ], "includes": [ { - "ordinal": 1, + "ordinal": 4, "raw_path": "$(CONFIG_DIR)/common.mk", "optional": false, "dynamic": true, "conditions": [], "location": { - "start_byte": 14, - "end_byte": 46, - "start_line": 2, + "start_byte": 94, + "end_byte": 126, + "start_line": 7, "start_column": 1, - "end_line": 3, + "end_line": 8, "end_column": 1 } } diff --git a/typos.toml b/typos.toml index e2341de..fe903c4 100644 --- a/typos.toml +++ b/typos.toml @@ -148,8 +148,6 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" -"artifact" = "artifact" -"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisation" = "atomization" "atomisations" = "atomizations" @@ -836,6 +834,7 @@ extend-ignore-re = [ "globalizers" = "globalizers" "globalizes" = "globalizes" "globalizing" = "globalizing" +"handwritten" = "handwritten" "harmonisable" = "harmonizable" "harmonisation" = "harmonization" "harmonisations" = "harmonizations" From 513e94340702e71bdc631b60cb6870420b6eff71 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 00:23:27 +0100 Subject: [PATCH 06/29] Refresh generated spelling policy Synchronize `typos.toml` with the latest authoritative Oxford spelling dictionary fetched by the repository stop hook. --- typos.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typos.toml b/typos.toml index fe903c4..e2341de 100644 --- a/typos.toml +++ b/typos.toml @@ -148,6 +148,8 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" +"artifact" = "artifact" +"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisation" = "atomization" "atomisations" = "atomizations" @@ -834,7 +836,6 @@ extend-ignore-re = [ "globalizers" = "globalizers" "globalizes" = "globalizes" "globalizing" = "globalizing" -"handwritten" = "handwritten" "harmonisable" = "harmonizable" "harmonisation" = "harmonization" "harmonisations" = "harmonizations" From 6348e931a6047cc8073a0df16166c57cd31d6b19 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 00:24:31 +0100 Subject: [PATCH 07/29] Resynchronize generated spelling policy Regenerate `typos.toml` after the authoritative Oxford spelling dictionary changed again during stop-hook execution. --- typos.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/typos.toml b/typos.toml index e2341de..fe903c4 100644 --- a/typos.toml +++ b/typos.toml @@ -148,8 +148,6 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" -"artifact" = "artifact" -"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisation" = "atomization" "atomisations" = "atomizations" @@ -836,6 +834,7 @@ extend-ignore-re = [ "globalizers" = "globalizers" "globalizes" = "globalizes" "globalizing" = "globalizing" +"handwritten" = "handwritten" "harmonisable" = "harmonizable" "harmonisation" = "harmonization" "harmonisations" = "harmonizations" From 1ca571573c6754a825dc1811558cef907751c93d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 02:07:09 +0100 Subject: [PATCH 08/29] Inject source-reader capability at CLI boundary Move ambient filesystem authority into the CLI composition root and pass a narrow `SourceReader` through `ProcessCapabilities`. Keep `read_path` responsible only for byte collection and stable open/read error classification. Exercise path input and both failure classes with injected readers, retain black-box CLI coverage, and document the new adapter ownership boundary. --- docs/design.md | 10 ++- docs/developers-guide.md | 10 +++ .../adr-0001-single-file-gnu-make-parse.md | 34 +++++++- src/adapters/cli.rs | 82 +++++++++++++++---- src/adapters/source.rs | 27 ++++-- tests/output_failures.rs | 41 +++++++++- tests/parse_bdd.rs | 29 ++++++- tests/source_adapter.rs | 66 +++++++++++++++ 8 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 tests/source_adapter.rs diff --git a/docs/design.md b/docs/design.md index 8703d67..41b17f5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -335,7 +335,11 @@ The first slice is implemented by `domain`, `ports`, `application`, and `adapters` modules in one crate. This is a boundary protection measure, not a pattern transplant: `MakefileParser` is the sole port because the upstream CST is the sole volatile external semantic boundary. Source input, JSON, and CLI -code remain ordinary edge adapters. +code remain ordinary edge adapters. The source adapter accepts a narrow +`SourceReader` capability interface so it can classify open and read failures +without resolving ambient authority. The CLI composition boundary constructs +the concrete ambient-backed reader once and injects it downwards; this +interface is an adapter test seam, not a domain port. The parser port is owned by the domain and called only by `parse_source`. Adapter implementations may compose upstream accessors and Rowan ranges, but @@ -350,8 +354,8 @@ digest rendering. Neither dependency expands the stable public contract. | Component | Responsibility | | -------------- | --------------------------------------------------------------------------------- | -| CLI front end | Parse the command and validate that exactly one source was supplied. | -| Source reader | Read one path or stdin into bytes without interpreting or normalizing it. | +| CLI front end | Parse the command, validate one source, and compose ambient process capabilities. | +| Source reader | Read one injected path capability or stdin without interpreting or normalizing. | | Parser adapter | Invoke `makefile-lossless` and return ordered owned observations and diagnostics. | | Fact collector | Flatten observations, attach conditions and locations, and assign ordinals. | | Location index | Convert byte offsets into one-based line and byte-column positions. | diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b0d4741..bc34082 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,6 +19,16 @@ external capability requires another port. CLI path and stdin filename values must continue to use OrthoConfig's explicit `ArgMatches` extraction, without file or environment layers. +`SourceReader` is the source adapter's narrow capability interface for opening +one requested UTF-8 path. `read_path` owns complete byte collection and stable +`SourceReadError` classification, but must never call `ambient_authority` +itself. The CLI boundary constructs `AmbientSourceReader` once in `run_from` +and bundles it with process streams in `ProcessCapabilities`; tests and +embedded callers may instead use `run_from_with_reader`. Do not use +`SourceReader` for stdin, directory traversal, include expansion, parsing, or +general filesystem access, and do not promote it into the domain-owned parser +port. + `ConditionKind` is the shared, closed domain and parser-port representation for `ifdef`, `ifndef`, `ifeq`, and `ifneq`. The parser adapter is its only producer; `SyntaxObservation` and report types are its permitted consumers. Extend the diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 31ac29d..86c3e9a 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -203,6 +203,13 @@ stop and resolve the conflict before editing `Cargo.toml`. tests, two passing doctests with one intentionally ignored, and clean formatting, Polonius type-checking, lint, documentation, diagram, and diff checks. +- [x] (2026-07-14) Injected ambient filesystem access at the CLI composition + boundary. Red compilation proved the `SourceReader` and + `run_from_with_reader` seams were absent; focused source-adapter, output- + failure, and BDD tests passed. The terminal repository gates then passed 49 + of 49 tests, two doctests with one intentionally ignored, and clean + formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, + spelling, Mermaid, and diff checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. @@ -263,7 +270,11 @@ stop and resolve the conflict before editing `Cargo.toml`. `cap_std::fs_utf8::File::open_ambient` with explicit ambient authority. Evidence: `src/adapters/source.rs` owns that call and maps its open and read failures into `SourceReadError`. Impact: the finding was stale and required - no source-reader change. + no source-reader change at that review milestone. The user subsequently + requested a stronger composition rule: ambient authority must be resolved + only by the CLI and injected into `read_path`. Impact: the explicit new + requirement supersedes the earlier no-change conclusion without changing + error or CLI contracts. ## Decision log @@ -344,6 +355,15 @@ stop and resolve the conflict before editing `Cargo.toml`. ports for implementation details. Permitted call sites and reuse policy are recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Wyvern review team. +- Decision: define `SourceReader` in the source adapter as a narrow capability + interface, not a domain port. `read_path` owns byte collection and + `SourceReadError` classification; `run_from` alone constructs the + ambient-backed implementation, while `run_from_with_reader` supports tests + and embedded composition through one `ProcessCapabilities` value. Rationale: + this removes ambient authority from the reusable read function without + transplanting filesystem concerns into the domain, introducing directory/ + include semantics, or exceeding the repository's four-argument limit. Date/ + Author: 2026-07-14 / Codex. ## Outcomes & retrospective @@ -354,9 +374,12 @@ The forked parser fix restores source-faithful `!=` assignments without a makeutil-specific fallback. Manual CLI acceptance, release-mode guardrails, and the Concordat subprocess and include-boundary trials all pass. Independent scrutineer validation repeated every deterministic gate. The implementation of -ADR-0001's single-file GNU Make parse slice is complete; exact terminal-diff -CodeRabbit certification is deferred to the pull request because the CLI -service became unavailable, as explicitly approved by the user. +ADR-0001's single-file GNU Make parse slice is complete. Ambient filesystem +authority is now composed once at the CLI boundary and injected through +`ProcessCapabilities`; fake readers prove the source-open and source-read +contracts without filesystem access. Exact terminal-diff CodeRabbit +certification is deferred to the pull request because the CLI service became +unavailable, as explicitly approved by the user. ## Context and orientation @@ -904,3 +927,6 @@ performance, and dependency decisions and to import and correct the OrthoConfig 0.8.0 guide. Implementation completed on 2026-07-13 with deterministic gates, manual acceptance, performance measurements, and external Concordat and include-boundary evidence recorded above. Pull request review remains pending. +Revised again on 2026-07-14 to inject the ambient filesystem capability at the +CLI boundary while preserving the stable source error and process diagnostic +contracts. diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 3f44628..b3ca9c4 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -3,13 +3,14 @@ use std::ffi::OsString; use camino::Utf8Path; +use cap_std::{AmbientAuthority, ambient_authority, fs_utf8::File}; use clap::{CommandFactory as _, FromArgMatches as _, Parser, Subcommand}; use ortho_config::{CliValueExtractor as _, OrthoConfig}; use serde::{Deserialize, Serialize}; use super::{ MakefileLosslessParser, - source::{read_path, read_stdin}, + source::{SourceReader, read_path, read_stdin}, }; use crate::{ domain::{ParseReport, ParseStatus}, @@ -52,10 +53,48 @@ pub struct ProcessOutcome { pub exit_code: u8, } -struct Streams<'stream> { +/// Process-owned input, output, diagnostic, and source-reader capabilities. +pub struct ProcessCapabilities<'stream> { stdin: &'stream mut dyn std::io::Read, stdout: &'stream mut dyn std::io::Write, stderr: &'stream mut dyn std::io::Write, + source_reader: &'stream dyn SourceReader, +} + +impl<'stream> ProcessCapabilities<'stream> { + /// Bundle process capabilities for an injected command invocation. + pub fn new( + stdin: &'stream mut dyn std::io::Read, + stdout: &'stream mut dyn std::io::Write, + stderr: &'stream mut dyn std::io::Write, + source_reader: &'stream dyn SourceReader, + ) -> Self { + Self { + stdin, + stdout, + stderr, + source_reader, + } + } +} + +struct AmbientSourceReader { + authority: AmbientAuthority, +} + +impl AmbientSourceReader { + fn new() -> Self { + Self { + authority: ambient_authority(), + } + } +} + +impl SourceReader for AmbientSourceReader { + fn open(&self, path: &Utf8Path) -> std::io::Result> { + File::open_ambient(path, self.authority) + .map(|file| Box::new(file) as Box) + } } /// Parse arguments, run the command, and write only contract streams. @@ -69,29 +108,40 @@ where I: IntoIterator, T: Into + Clone, { - let mut streams = Streams { - stdin, - stdout, - stderr, - }; + let source_reader = AmbientSourceReader::new(); + run_from_with_reader( + command_line, + ProcessCapabilities::new(stdin, stdout, stderr, &source_reader), + ) +} + +/// Parse arguments and run the command with an injected source-reader capability. +pub fn run_from_with_reader( + command_line: I, + mut capabilities: ProcessCapabilities<'_>, +) -> ProcessOutcome +where + I: IntoIterator, + T: Into + Clone, +{ let command = Cli::command(); let matches = match command.try_get_matches_from(command_line) { Ok(matches) => matches, - Err(error) => return render_clap_error(&error, &mut streams), + Err(error) => return render_clap_error(&error, &mut capabilities), }; let cli = match Cli::from_arg_matches(&matches) { Ok(cli) => cli, Err(error) => { - let _write_result = streams.stderr.write_all(error.to_string().as_bytes()); + let _write_result = capabilities.stderr.write_all(error.to_string().as_bytes()); return ProcessOutcome { exit_code: 2 }; } }; match cli.command { - Command::Parse(parse_arguments) => run_parse(&parse_arguments, &matches, &mut streams), + Command::Parse(parse_arguments) => run_parse(&parse_arguments, &matches, &mut capabilities), } } -fn render_clap_error(error: &clap::Error, streams: &mut Streams<'_>) -> ProcessOutcome { +fn render_clap_error(error: &clap::Error, streams: &mut ProcessCapabilities<'_>) -> ProcessOutcome { let exit_code = if error.use_stderr() { 2 } else { 0 }; let writer = if error.use_stderr() { &mut streams.stderr @@ -105,7 +155,7 @@ fn render_clap_error(error: &clap::Error, streams: &mut Streams<'_>) -> ProcessO fn run_parse( parsed_arguments: &ParseArgs, matches: &clap::ArgMatches, - streams: &mut Streams<'_>, + streams: &mut ProcessCapabilities<'_>, ) -> ProcessOutcome { let Some(parse_matches) = matches.subcommand_matches("parse") else { return fatal( @@ -139,7 +189,7 @@ fn extract_explicit_arguments( fn produce_report( arguments: ParseArgs, - streams: &mut Streams<'_>, + streams: &mut ProcessCapabilities<'_>, ) -> Result { let (bytes, logical_path) = read_input(arguments, streams)?; match parse_source(&bytes, &logical_path, &MakefileLosslessParser) { @@ -151,7 +201,7 @@ fn produce_report( } } -fn emit_report(report: &ParseReport, streams: &mut Streams<'_>) -> ProcessOutcome { +fn emit_report(report: &ParseReport, streams: &mut ProcessCapabilities<'_>) -> ProcessOutcome { let mut document = match serde_json::to_vec(&report) { Ok(document) => document, Err(error) => return fatal(streams.stderr, "json-serialize", &error.to_string()), @@ -167,7 +217,7 @@ fn emit_report(report: &ParseReport, streams: &mut Streams<'_>) -> ProcessOutcom fn read_input( arguments: ParseArgs, - streams: &mut Streams<'_>, + streams: &mut ProcessCapabilities<'_>, ) -> Result<(Vec, String), ProcessOutcome> { if arguments.path == "-" { let logical_path = arguments.stdin_filename.ok_or_else(|| { @@ -189,7 +239,7 @@ fn read_input( )); } let path = Utf8Path::new(&arguments.path); - read_path(path) + read_path(streams.source_reader, path) .map(|bytes| (bytes, arguments.path)) .map_err(|error| fatal(streams.stderr, error.operation(), &error.to_string())) } diff --git a/src/adapters/source.rs b/src/adapters/source.rs index 177f9fd..d806ed3 100644 --- a/src/adapters/source.rs +++ b/src/adapters/source.rs @@ -3,9 +3,18 @@ use std::io::Read as _; use camino::Utf8Path; -use cap_std::{ambient_authority, fs_utf8::File}; use thiserror::Error; +/// Capability for opening one logical source path as a byte stream. +pub trait SourceReader { + /// Open `path` for reading without resolving ambient authority. + /// + /// # Errors + /// + /// Returns an input/output error when the capability cannot open `path`. + fn open(&self, path: &Utf8Path) -> std::io::Result>; +} + /// Source input failure classified for stable CLI diagnostics. #[derive(Debug, Error)] pub enum SourceReadError { @@ -38,18 +47,20 @@ impl SourceReadError { } } -/// Read exact bytes from a UTF-8 path using an explicit ambient authority. +/// Read exact bytes from a UTF-8 path using an injected reader capability. /// /// # Errors /// /// Returns [`SourceReadError`] when the source cannot be opened or read. -pub fn read_path(path: &Utf8Path) -> Result, SourceReadError> { +pub fn read_path( + reader: &(impl SourceReader + ?Sized), + path: &Utf8Path, +) -> Result, SourceReadError> { let display_path = path.as_str().to_owned(); - let mut file = - File::open_ambient(path, ambient_authority()).map_err(|source| SourceReadError::Open { - path: display_path.clone(), - source, - })?; + let mut file = reader.open(path).map_err(|source| SourceReadError::Open { + path: display_path.clone(), + source, + })?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) .map_err(|source| SourceReadError::Read { diff --git a/tests/output_failures.rs b/tests/output_failures.rs index 40e4b8b..b3e371a 100644 --- a/tests/output_failures.rs +++ b/tests/output_failures.rs @@ -1,10 +1,33 @@ //! Injected output failures verify the stable writer error boundary. -use makeutil::adapters::cli::run_from; +use camino::Utf8Path; +use makeutil::adapters::{ + cli::{ProcessCapabilities, run_from, run_from_with_reader}, + source::SourceReader, +}; use rstest::rstest; struct FailingWriter; +struct ReadFailureSourceReader; + +impl SourceReader for ReadFailureSourceReader { + fn open(&self, _path: &Utf8Path) -> std::io::Result> { + Ok(Box::new(FailingReader)) + } +} + +struct FailingReader; + +impl std::io::Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broken source stream", + )) + } +} + impl std::io::Write for FailingWriter { fn write(&mut self, _buffer: &[u8]) -> std::io::Result { Err(std::io::Error::new( @@ -30,3 +53,19 @@ fn broken_stdout_exits_two_with_stable_operation() { assert_eq!(outcome.exit_code, 2); assert!(String::from_utf8_lossy(&stderr).contains("makeutil: stdout-write:")); } + +#[rstest] +fn broken_path_reader_exits_two_with_stable_operation() { + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let capabilities = ProcessCapabilities::new( + &mut stdin, + &mut stdout, + &mut stderr, + &ReadFailureSourceReader, + ); + let outcome = run_from_with_reader(["makeutil", "parse", "Makefile"], capabilities); + assert_eq!(outcome.exit_code, 2); + assert!(String::from_utf8_lossy(&stderr).contains("makeutil: source-read:")); +} diff --git a/tests/parse_bdd.rs b/tests/parse_bdd.rs index b9f934c..1779c3c 100644 --- a/tests/parse_bdd.rs +++ b/tests/parse_bdd.rs @@ -1,6 +1,12 @@ //! Behavioural acceptance tests for the parse command. -use makeutil::adapters::cli::run_from; +use std::io::{Cursor, Read}; + +use camino::Utf8Path; +use makeutil::adapters::{ + cli::{ProcessCapabilities, run_from_with_reader}, + source::SourceReader, +}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; @@ -13,6 +19,22 @@ struct World { exit_code: Option, } +struct FixtureSourceReader; + +impl SourceReader for FixtureSourceReader { + fn open(&self, path: &Utf8Path) -> std::io::Result> { + if path == Utf8Path::new("tests/fixtures/makefiles/all-facts.mk") { + return Ok(Box::new(Cursor::new(include_bytes!( + "fixtures/makefiles/all-facts.mk" + )))); + } + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "fixture is absent", + )) + } +} + #[fixture] fn world() -> World { World { @@ -107,12 +129,13 @@ fn source_open(world: &World) { fn run_world(world: &mut World) { let mut stdin = world.stdin.as_slice(); - let outcome = run_from( - world.arguments.clone(), + let capabilities = ProcessCapabilities::new( &mut stdin, &mut world.stdout, &mut world.stderr, + &FixtureSourceReader, ); + let outcome = run_from_with_reader(world.arguments.clone(), capabilities); world.exit_code = Some(outcome.exit_code); } diff --git a/tests/source_adapter.rs b/tests/source_adapter.rs new file mode 100644 index 0000000..75ff9d1 --- /dev/null +++ b/tests/source_adapter.rs @@ -0,0 +1,66 @@ +//! Injected source readers verify path input without ambient file-system access. + +use std::io::{Cursor, Read}; + +use camino::Utf8Path; +use googletest::prelude::*; +use makeutil::adapters::source::{SourceReader, read_path}; +use rstest::rstest; + +struct MemorySourceReader; + +impl SourceReader for MemorySourceReader { + fn open(&self, _path: &Utf8Path) -> std::io::Result> { + Ok(Box::new(Cursor::new(b"all:\n\techo ok\n"))) + } +} + +struct OpenFailureSourceReader; + +impl SourceReader for OpenFailureSourceReader { + fn open(&self, _path: &Utf8Path) -> std::io::Result> { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied by test capability", + )) + } +} + +struct ReadFailureSourceReader; + +impl SourceReader for ReadFailureSourceReader { + fn open(&self, _path: &Utf8Path) -> std::io::Result> { + Ok(Box::new(FailingReader)) + } +} + +struct FailingReader; + +impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broken source stream", + )) + } +} + +#[rstest] +fn injected_reader_supplies_source_bytes() -> googletest::Result<()> { + let bytes = read_path(&MemorySourceReader, Utf8Path::new("Makefile"))?; + verify_that!(bytes, eq(b"all:\n\techo ok\n")) +} + +#[rstest] +fn open_failures_keep_the_stable_operation() -> googletest::Result<()> { + let error = read_path(&OpenFailureSourceReader, Utf8Path::new("Makefile")) + .expect_err("opening should fail"); + verify_that!(error.operation(), eq("source-open")) +} + +#[rstest] +fn read_failures_keep_the_stable_operation() -> googletest::Result<()> { + let error = read_path(&ReadFailureSourceReader, Utf8Path::new("Makefile")) + .expect_err("reading should fail"); + verify_that!(error.operation(), eq("source-read")) +} From ea0adaa5559bf6956c9f96280b1fcfa49786bc1b Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 12:37:44 +0100 Subject: [PATCH 09/29] Address terminal ADR-0001 review findings Route the remaining Clap construction failure through the stable CLI fatal diagnostic and split Makefile item conversion into focused adapter-private constructors. Correct hashing ownership and repository orientation, clarify imported-guide provenance, repair the GPUI reset example, and record verified skips and gate evidence in the ExecPlan. --- docs/design.md | 12 +- docs/developers-guide.md | 7 ++ .../adr-0001-single-file-gnu-make-parse.md | 75 +++++++---- docs/ortho-config-users-guide.md | 12 +- docs/rstest-bdd-users-guide.md | 16 ++- src/adapters/cli.rs | 5 +- src/adapters/makefile.rs | 119 +++++++++++------- 7 files changed, 161 insertions(+), 85 deletions(-) diff --git a/docs/design.md b/docs/design.md index 41b17f5..f0bfc4c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -365,12 +365,12 @@ The package may expose a Rust library internally for unit tests, but only the CLI and JSON schema form a supported integration contract in the first release. The domain owns report types, source spans and locations, conditional ancestry, -ordinal assignment, diagnostic ordering, source hashing, and complete versus -recovered classification. A domain-owned parser port accepts UTF-8 text and -returns ordered makeutil-owned syntax observations, source spans, and -diagnostics. The `makefile-lossless` adapter implements the port and proves its -own complete-tree round trip; it never returns Rowan nodes, upstream errors, or -rendered CST bytes through the port. +ordinal assignment, diagnostic ordering, the `SourceIdentity` contract, and +complete versus recovered classification. A domain-owned parser port accepts +UTF-8 text and returns ordered makeutil-owned syntax observations, source +spans, and diagnostics. The `makefile-lossless` adapter implements the port and +proves its own complete-tree round trip; it never returns Rowan nodes, upstream +errors, or rendered CST bytes through the port. The application service calculates SHA-256 over the exact input bytes while `parse_source` constructs `SourceIdentity`. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index bc34082..3a9997a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -41,6 +41,13 @@ and ignore-error accessors are sensitive to modifier order. It may be called only while translating an upstream recipe into a `RecipeObservation`; it is not a general Make lexer, domain helper, or reusable port. +`rule_observation`, `variable_observation`, and `include_observation` are +private makefile-adapter constructors called only by `collect_items`. They keep +upstream field validation and source-span mapping beside CST translation. They +are not domain ports or general utilities; reuse outside `collect_items` +requires a new adapter-owned call-site with the same complete-observation +contract, not a move into the domain or ports modules. + The CLI adapter's private extraction, report-production, and report-emission helpers divide its orchestration into focused steps. They may be called only by the CLI adapter and must remain ordinary private functions. Promote one to a diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 86c3e9a..a8eafe4 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -210,18 +210,24 @@ stop and resolve the conflict before editing `Cargo.toml`. of 49 tests, two doctests with one intentionally ignored, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. +- [x] (2026-07-14) Corrected documentation ownership and orientation drift and + applied the valid fatal CLI helper and private `collect_items` constructor + fixes found during terminal review. The independent scrutineer confirmed 49 + of 49 tests, two passing doctests with one intentionally ignored, and clean + `make check-fmt`, `make typecheck`, `make lint`, `make test`, rustdoc, + Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. ## Surprises & discoveries -- Observation: the repository contains only greeting and test stubs; there is - no existing parser, CLI, model, I/O, or test abstraction to extend. Evidence: - Leta finds only `greet`, `main`, and - `replace_this_stub_when_real_tests_exist`; `Cargo.toml` has no dependencies. - Impact: this is the first real application boundary, but it should remain a - small set of cohesive modules rather than receive a framework-sized layout. +- Observation: at planning start, the repository contained only greeting and + test stubs; there was no parser, CLI, model, I/O, or test abstraction to + extend. Evidence: Leta found only `greet`, `main`, and + `replace_this_stub_when_real_tests_exist`; `Cargo.toml` had no dependencies. + Impact: this was the first real application boundary, but it needed to remain + a small set of cohesive modules rather than receive a framework-sized layout. - Observation: upstream 0.3.40 exports `Parse`, `PositionedParseError`, `MakefileVariant`, and lossless CST types and retains parser errors alongside a tree. Evidence: the 0.3.40 docs.rs and tagged source expose these APIs and @@ -275,6 +281,15 @@ stop and resolve the conflict before editing `Cargo.toml`. only by the CLI and injected into `read_path`. Impact: the explicit new requirement supersedes the earlier no-change conclusion without changing error or CLI contracts. +- Observation: the claimed duplicate generated header in `typos.toml` was + stale. Evidence: the file contains one two-line header emitted verbatim by + `scripts/typos_rollout.py`. Impact: generated spelling policy required no + manual edit. +- Observation: a tracing and metrics warning did not apply to this approved + one-shot CLI slice. Evidence: stable operation identifiers are its documented + observability surface, success and recovered parsing keep stderr empty, and + the design explicitly defers metrics. Impact: no subscriber, recorder, or new + telemetry dependency was added during terminal review. ## Decision log @@ -337,10 +352,11 @@ stop and resolve the conflict before editing `Cargo.toml`. 2026-07-13 / Logisphere-reviewed Codex planning team. - Decision: let the parser adapter return ordered makeutil-owned observations and source spans; keep round-trip bytes in adapter tests only. Rationale: - location conversion, ordinals, hashing, and status are domain policy. - Upstream CST renderings and error types must not leak through the - domain-owned port. Date/Author: 2026-07-13 / Logisphere-reviewed Codex - planning team. + location conversion, ordinals, and status are domain policy; exact-byte + hashing is application-service policy. Upstream CST renderings and error + types must not leak through the domain-owned port. Date/Author: 2026-07-13 / + Logisphere-reviewed Codex planning team. Ownership wording clarified on + 2026-07-14 during terminal documentation review. - Decision: serialize to memory before stdout and permit partial stdout only when the operating system accepts a prefix before an output failure. Rationale: the process can prevent serialization failures from writing JSON, @@ -364,6 +380,13 @@ stop and resolve the conflict before editing `Cargo.toml`. transplanting filesystem concerns into the domain, introducing directory/ include semantics, or exceeding the repository's four-argument limit. Date/ Author: 2026-07-14 / Codex. +- Decision: do not derive an automatic `MakefileParser` mock for integration + tests. Rationale: a `cfg_attr(test, automock)` mock is not exported when the + library is compiled as a dependency of an integration-test crate. Exporting + it would require production `mockall` or a public test-support feature and + API solely for test ceremony; the existing small manual fake exercises the + port without widening production dependencies or surface. Date/Author: + 2026-07-14 / Wyvern review team. ## Outcomes & retrospective @@ -384,10 +407,12 @@ unavailable, as explicitly approved by the user. ## Context and orientation The repository is a Rust 2024 application compiled on the pinned nightly with -Polonius. `src/lib.rs` contains a temporary `greet` function, `src/main.rs` -prints a greeting under a temporary lint exception, and `tests/stub.rs` is a -disposable test. Replace those stubs only after real tests establish the red -stage. +Polonius. `src/domain/` owns schema-v1 report types and source-location policy; +`src/ports.rs` owns the parser contract; and `src/application.rs` validates, +hashes, and assembles one source report. `src/adapters/` contains the +makefile-lossless parser, injected source capability, and CLI/reporting edges. +`src/main.rs` is the composition root. Unit, property, schema, behavioural, and +end-to-end tests under `tests/` replace the original greeting stubs. [ADR-0001](../adrs/0001-single-file-gnu-make-parse.md) governs scope and the stable subprocess contract. [The technical design](../design.md) defines JSON @@ -439,14 +464,14 @@ CLI adapter ──> composition root ──> source reader parse report ──> composition root ──> JSON reporter ──> stdout / process exit ``` -The domain owns schema-v1 value types, source locations, conditional ancestry, -global ordinal assignment, exact-byte SHA-256 metadata, diagnostic order, and -complete/recovered classification. The application service validates one source -byte buffer as UTF-8, hashes it, and coordinates its logical path with the -parser port. Adapters own OrthoConfig/clap, capability-oriented file or stdin -reading, upstream parsing into ordered observations, Serde serialization, -streams, and process exit. Adapters never call each other; `src/main.rs` is the -composition root. +The domain owns schema-v1 value types, the `SourceIdentity` contract, source +locations, conditional ancestry, global ordinal assignment, diagnostic order, +and complete/recovered classification. The application service validates one +source byte buffer as UTF-8, calculates its exact-byte SHA-256 digest, and +coordinates its logical path with the parser port. Adapters own +OrthoConfig/clap, capability-oriented file or stdin reading, upstream parsing +into ordered observations, Serde serialization, streams, and process exit. +Adapters never call each other; `src/main.rs` is the composition root. ## Plan of work @@ -929,4 +954,8 @@ manual acceptance, performance measurements, and external Concordat and include-boundary evidence recorded above. Pull request review remains pending. Revised again on 2026-07-14 to inject the ambient filesystem capability at the CLI boundary while preserving the stable source error and process diagnostic -contracts. +contracts. Terminal documentation review then clarified hashing ownership and +replaced planning-time scaffold descriptions in the current repository +orientation and applied the valid CLI and parser-helper fixes. Independent +scrutineer validation passed all post-correction gates; exact terminal-diff +CodeRabbit certification remains pending in the pull request. diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 237cb1d..c3da6a7 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -1,5 +1,11 @@ # OrthoConfig user's guide +> **Upstream reference:** This imported guide describes the +> [OrthoConfig repository](https://github.com/leynos/ortho-config), not the +> makeutil workspace. Repository-relative paths, `make` commands, examples, +> tests, and assets mentioned below—including Hello World and +> `config/overrides.toml`—belong to that upstream repository. + `OrthoConfig` is a Rust library that unifies command‑line arguments, environment variables and configuration files into a single, strongly typed configuration struct. It is inspired by tools such as `esbuild` and is designed @@ -46,9 +52,9 @@ values from multiple sources. The core features are: parser. Vector fields honour the append strategy by default, while maps use keyed merging unless replacement is requested. -The workspace bundles an executable Hello World example under -`examples/hello_world`. It layers defaults, environment variables, and CLI -flags via the derive macro; see its +The upstream OrthoConfig workspace bundles an executable Hello World example +under `examples/hello_world`. It layers defaults, environment variables, and +CLI flags via the derive macro; see its [README](https://github.com/leynos/ortho-config/blob/main/examples/hello_world/README.md) for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven Development) scenarios that validate behaviour end-to-end. diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index fb5d2fe..1ac1dd5 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1263,9 +1263,10 @@ fn scenario_opening_second_window_starts_from_reset_state( } ``` -The second snippet shows the `#[given]` that opens a fresh window. It -defensively re-runs the reset before storing handles and observes the -`stale_window_count` invariant that the regression suite encodes: +The second snippet shows the `#[given]` that opens a fresh window. It seeds a +stale stored handle, defensively re-runs the reset before storing the fresh +handles, and observes the `stale_window_count` invariant that the regression +suite encodes: ```rust,no_run # use rstest_bdd_macros::given; @@ -1275,13 +1276,16 @@ defensively re-runs the reset before storing handles and observes the fn fresh_gpui_window_is_opened( #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext, ) { - let stale_window_count = with_state(|state| usize::from(state.window.is_some())); - reset_state_before_assignment(); - let (entity, visual_context) = context.add_window_view(|_context| CounterView::default()); let window = visual_context.window_handle(); + // Arrange stale stored state so this example proves that the reset works. + with_state(|state| state.window = Some(window.clone())); + reset_state_before_assignment(); + let stale_window_count = + with_state(|state| usize::from(state.window.is_some())); + with_state(|state| { state.entity = Some(entity); state.window = Some(window); diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index b3ca9c4..8163fef 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -131,10 +131,7 @@ where }; let cli = match Cli::from_arg_matches(&matches) { Ok(cli) => cli, - Err(error) => { - let _write_result = capabilities.stderr.write_all(error.to_string().as_bytes()); - return ProcessOutcome { exit_code: 2 }; - } + Err(error) => return fatal(capabilities.stderr, "cli", &error.to_string()), }; match cli.command { Command::Parse(parse_arguments) => run_parse(&parse_arguments, &matches, &mut capabilities), diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index 590542d..7782ce0 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -1,6 +1,15 @@ //! `makefile-lossless` 0.3.40 adapter for the domain-owned parser port. -use makefile_lossless::{Conditional, Makefile, MakefileItem, Parse, SyntaxKind}; +use makefile_lossless::{ + Conditional, + Include, + Makefile, + MakefileItem, + Parse, + Rule, + SyntaxKind, + VariableDefinition, +}; use rowan::ast::AstNode as _; use crate::{ @@ -43,52 +52,13 @@ fn collect_items( for item in items { match item { MakefileItem::Rule(rule) => { - let recipes = rule - .recipe_nodes() - .map(|recipe| { - let text = recipe.text(); - let modifiers = recipe_modifiers(&text); - Ok(RecipeObservation { - silent: modifiers.silent, - ignore_errors: modifiers.ignore_errors, - always_execute: modifiers.always_execute, - text, - span: span(recipe.text_range(), source_length)?, - }) - }) - .collect::, ParserPortError>>()?; - observations.push(SyntaxObservation::Rule { - targets: rule.targets().collect(), - prerequisites: rule.prerequisites().collect(), - double_colon: rule.is_double_colon(), - conditions: conditions.to_vec(), - recipes, - span: span(rule.syntax().text_range(), source_length)?, - }); + observations.push(rule_observation(&rule, conditions, source_length)?); } MakefileItem::Variable(variable) => { - observations.push(SyntaxObservation::Variable { - name: variable.name().ok_or(ParserPortError::MissingField { - field: "variable-name", - })?, - operator: variable.assignment_operator().unwrap_or_default(), - raw_value: variable.raw_value().unwrap_or_default(), - exported: variable.is_export(), - overridden: variable.is_override(), - define_block: variable.is_define(), - conditions: conditions.to_vec(), - span: span(variable.syntax().text_range(), source_length)?, - }); + observations.push(variable_observation(&variable, conditions, source_length)?); } MakefileItem::Include(include) => { - observations.push(SyntaxObservation::Include { - raw_path: include.path().ok_or(ParserPortError::MissingField { - field: "include-path", - })?, - optional: include.is_optional(), - conditions: conditions.to_vec(), - span: span(include.syntax().text_range(), source_length)?, - }); + observations.push(include_observation(&include, conditions, source_length)?); } MakefileItem::Conditional(conditional) => { collect_conditional(&conditional, conditions, source_length, observations)?; @@ -99,6 +69,69 @@ fn collect_items( Ok(()) } +fn rule_observation( + rule: &Rule, + conditions: &[ConditionObservation], + source_length: usize, +) -> Result { + let recipes = rule + .recipe_nodes() + .map(|recipe| { + let text = recipe.text(); + let modifiers = recipe_modifiers(&text); + Ok(RecipeObservation { + silent: modifiers.silent, + ignore_errors: modifiers.ignore_errors, + always_execute: modifiers.always_execute, + text, + span: span(recipe.text_range(), source_length)?, + }) + }) + .collect::, ParserPortError>>()?; + Ok(SyntaxObservation::Rule { + targets: rule.targets().collect(), + prerequisites: rule.prerequisites().collect(), + double_colon: rule.is_double_colon(), + conditions: conditions.to_vec(), + recipes, + span: span(rule.syntax().text_range(), source_length)?, + }) +} + +fn variable_observation( + variable: &VariableDefinition, + conditions: &[ConditionObservation], + source_length: usize, +) -> Result { + Ok(SyntaxObservation::Variable { + name: variable.name().ok_or(ParserPortError::MissingField { + field: "variable-name", + })?, + operator: variable.assignment_operator().unwrap_or_default(), + raw_value: variable.raw_value().unwrap_or_default(), + exported: variable.is_export(), + overridden: variable.is_override(), + define_block: variable.is_define(), + conditions: conditions.to_vec(), + span: span(variable.syntax().text_range(), source_length)?, + }) +} + +fn include_observation( + include: &Include, + conditions: &[ConditionObservation], + source_length: usize, +) -> Result { + Ok(SyntaxObservation::Include { + raw_path: include.path().ok_or(ParserPortError::MissingField { + field: "include-path", + })?, + optional: include.is_optional(), + conditions: conditions.to_vec(), + span: span(include.syntax().text_range(), source_length)?, + }) +} + fn collect_conditional( conditional: &Conditional, outer: &[ConditionObservation], From 05774c09bef28c81c4c1a5dd23cfa7839998bf7c Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 14 Jul 2026 20:06:53 +0100 Subject: [PATCH 10/29] Share source-reader mocks across integration tests Add `mockall` as a development-only dependency and define one shared `MockSourceReader` under `tests/common`. Reuse a separately included failing reader only in suites that exercise post-open failures, keeping every test binary free of unused-code warnings. Document why the mock remains outside the production library and record the independent gate evidence in the ExecPlan. --- Cargo.lock | 42 +++++++++++ Cargo.toml | 1 + docs/developers-guide.md | 6 ++ .../adr-0001-single-file-gnu-make-parse.md | 29 +++++--- tests/common/failing_reader.rs | 17 +++++ tests/common/mod.rs | 15 ++++ tests/output_failures.rs | 43 ++++-------- tests/parse_bdd.rs | 40 +++++------ tests/source_adapter.rs | 69 +++++++------------ 9 files changed, 157 insertions(+), 105 deletions(-) create mode 100644 tests/common/failing_reader.rs create mode 100644 tests/common/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7101173..96ff310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,6 +519,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dunce" version = "1.0.5" @@ -676,6 +682,15 @@ dependencies = [ "num", ] +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -1233,6 +1248,7 @@ dependencies = [ "insta", "jsonschema", "makefile-lossless", + "mockall", "ortho_config", "pretty_assertions", "proptest", @@ -1281,6 +1297,32 @@ dependencies = [ "unicase", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "newt-hype" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index e2a5e54..c664fd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ assert_cmd = "2.2.2" googletest = "0.14.3" insta = { version = "1.48.0", features = ["json"] } jsonschema = { version = "0.47.0", default-features = false } +mockall = "0.13.1" pretty_assertions = "1.4.1" proptest = "1.11.0" rstest = "0.26.1" diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 3a9997a..90801a3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -29,6 +29,12 @@ embedded callers may instead use `run_from_with_reader`. Do not use general filesystem access, and do not promote it into the domain-owned parser port. +Integration tests share `MockSourceReader` from `tests/common/mod.rs`, where +`mockall` remains a development-only dependency. Include +`tests/common/failing_reader.rs` only in suites that exercise post-open read +failures; do not compile shared test helpers into binaries that do not use +them, and do not suppress the resulting unused-code warnings. + `ConditionKind` is the shared, closed domain and parser-port representation for `ifdef`, `ifndef`, `ifeq`, and `ifneq`. The parser adapter is its only producer; `SyntaxObservation` and report types are its permitted consumers. Extend the diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index a8eafe4..e28ec70 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -216,6 +216,13 @@ stop and resolve the conflict before editing `Cargo.toml`. of 49 tests, two passing doctests with one intentionally ignored, and clean `make check-fmt`, `make typecheck`, `make lint`, `make test`, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. +- [x] (2026-07-14) Replaced duplicated integration-test source readers with one + `mockall` definition under `tests/common`, keeping mock code out of the + production library. The scrutineer independently confirmed 49 of 49 tests, + two passing doctests with one intentionally ignored, and clean formatting, + Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, + Mermaid, and diff checks. All targets compiled with warnings denied and no + unused test helper. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. @@ -380,13 +387,15 @@ stop and resolve the conflict before editing `Cargo.toml`. transplanting filesystem concerns into the domain, introducing directory/ include semantics, or exceeding the repository's four-argument limit. Date/ Author: 2026-07-14 / Codex. -- Decision: do not derive an automatic `MakefileParser` mock for integration - tests. Rationale: a `cfg_attr(test, automock)` mock is not exported when the - library is compiled as a dependency of an integration-test crate. Exporting - it would require production `mockall` or a public test-support feature and - API solely for test ceremony; the existing small manual fake exercises the - port without widening production dependencies or surface. Date/Author: - 2026-07-14 / Wyvern review team. +- Decision: share a `MockSourceReader` definition under `tests/common` rather + than derive it on the production trait. Rationale: a + `cfg_attr(test, automock)` type is not exported when the library is compiled + as a dependency of an integration-test crate. A test-only `mockall::mock!` + definition removes duplicated readers without adding `mockall`, a public + test-support feature, or generated mocks to the production surface. Keep the + failing stream in a separate shared file included only by suites that use it, + so warnings remain denied without suppressions. Date/Author: 2026-07-14 / + User and Codex. ## Outcomes & retrospective @@ -958,4 +967,8 @@ contracts. Terminal documentation review then clarified hashing ownership and replaced planning-time scaffold descriptions in the current repository orientation and applied the valid CLI and parser-helper fixes. Independent scrutineer validation passed all post-correction gates; exact terminal-diff -CodeRabbit certification remains pending in the pull request. +CodeRabbit certification remains pending in the pull request. The shared +source-reader test double was subsequently moved to a test-only common module +because Cargo does not export `cfg(test)` automatic mocks to integration-test +crates. Independent post-change repository gates passed with warnings denied +across every integration-test binary. diff --git a/tests/common/failing_reader.rs b/tests/common/failing_reader.rs new file mode 100644 index 0000000..d5416d2 --- /dev/null +++ b/tests/common/failing_reader.rs @@ -0,0 +1,17 @@ +//! Shared reader failure used by source-boundary integration tests. + +use std::io::Read; + +/// Return a reader that fails while consuming an opened source. +pub fn failing_reader() -> Box { Box::new(FailingReader) } + +struct FailingReader; + +impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broken source stream", + )) + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..25a70b6 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,15 @@ +//! Shared test-only capabilities for integration suites. + +use std::io::Read; + +use camino::Utf8Path; +use makeutil::adapters::source::SourceReader; + +mockall::mock! { + /// Mock source-reader capability shared by integration tests. + pub SourceReader {} + + impl SourceReader for SourceReader { + fn open(&self, path: &Utf8Path) -> std::io::Result>; + } +} diff --git a/tests/output_failures.rs b/tests/output_failures.rs index b3e371a..a1af4d2 100644 --- a/tests/output_failures.rs +++ b/tests/output_failures.rs @@ -1,33 +1,16 @@ //! Injected output failures verify the stable writer error boundary. -use camino::Utf8Path; -use makeutil::adapters::{ - cli::{ProcessCapabilities, run_from, run_from_with_reader}, - source::SourceReader, -}; +mod common; +#[path = "common/failing_reader.rs"] +mod failing_reader; + +use common::MockSourceReader; +use failing_reader::failing_reader; +use makeutil::adapters::cli::{ProcessCapabilities, run_from, run_from_with_reader}; use rstest::rstest; struct FailingWriter; -struct ReadFailureSourceReader; - -impl SourceReader for ReadFailureSourceReader { - fn open(&self, _path: &Utf8Path) -> std::io::Result> { - Ok(Box::new(FailingReader)) - } -} - -struct FailingReader; - -impl std::io::Read for FailingReader { - fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "broken source stream", - )) - } -} - impl std::io::Write for FailingWriter { fn write(&mut self, _buffer: &[u8]) -> std::io::Result { Err(std::io::Error::new( @@ -56,15 +39,15 @@ fn broken_stdout_exits_two_with_stable_operation() { #[rstest] fn broken_path_reader_exits_two_with_stable_operation() { + let mut source_reader = MockSourceReader::new(); + source_reader + .expect_open() + .returning(|_| Ok(failing_reader())); let mut stdin = std::io::empty(); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let capabilities = ProcessCapabilities::new( - &mut stdin, - &mut stdout, - &mut stderr, - &ReadFailureSourceReader, - ); + let capabilities = + ProcessCapabilities::new(&mut stdin, &mut stdout, &mut stderr, &source_reader); let outcome = run_from_with_reader(["makeutil", "parse", "Makefile"], capabilities); assert_eq!(outcome.exit_code, 2); assert!(String::from_utf8_lossy(&stderr).contains("makeutil: source-read:")); diff --git a/tests/parse_bdd.rs b/tests/parse_bdd.rs index 1779c3c..e5b3099 100644 --- a/tests/parse_bdd.rs +++ b/tests/parse_bdd.rs @@ -1,12 +1,12 @@ //! Behavioural acceptance tests for the parse command. -use std::io::{Cursor, Read}; +mod common; + +use std::io::Cursor; use camino::Utf8Path; -use makeutil::adapters::{ - cli::{ProcessCapabilities, run_from_with_reader}, - source::SourceReader, -}; +use common::MockSourceReader; +use makeutil::adapters::cli::{ProcessCapabilities, run_from_with_reader}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; @@ -19,22 +19,6 @@ struct World { exit_code: Option, } -struct FixtureSourceReader; - -impl SourceReader for FixtureSourceReader { - fn open(&self, path: &Utf8Path) -> std::io::Result> { - if path == Utf8Path::new("tests/fixtures/makefiles/all-facts.mk") { - return Ok(Box::new(Cursor::new(include_bytes!( - "fixtures/makefiles/all-facts.mk" - )))); - } - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "fixture is absent", - )) - } -} - #[fixture] fn world() -> World { World { @@ -128,12 +112,24 @@ fn source_open(world: &World) { } fn run_world(world: &mut World) { + let mut source_reader = MockSourceReader::new(); + source_reader.expect_open().returning(|path| { + if path == Utf8Path::new("tests/fixtures/makefiles/all-facts.mk") { + return Ok(Box::new(Cursor::new(include_bytes!( + "fixtures/makefiles/all-facts.mk" + )))); + } + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "fixture is absent", + )) + }); let mut stdin = world.stdin.as_slice(); let capabilities = ProcessCapabilities::new( &mut stdin, &mut world.stdout, &mut world.stderr, - &FixtureSourceReader, + &source_reader, ); let outcome = run_from_with_reader(world.arguments.clone(), capabilities); world.exit_code = Some(outcome.exit_code); diff --git a/tests/source_adapter.rs b/tests/source_adapter.rs index 75ff9d1..8038a94 100644 --- a/tests/source_adapter.rs +++ b/tests/source_adapter.rs @@ -1,66 +1,45 @@ //! Injected source readers verify path input without ambient file-system access. -use std::io::{Cursor, Read}; +mod common; +#[path = "common/failing_reader.rs"] +mod failing_reader; + +use std::io::Cursor; use camino::Utf8Path; +use common::MockSourceReader; +use failing_reader::failing_reader; use googletest::prelude::*; -use makeutil::adapters::source::{SourceReader, read_path}; +use makeutil::adapters::source::read_path; use rstest::rstest; -struct MemorySourceReader; - -impl SourceReader for MemorySourceReader { - fn open(&self, _path: &Utf8Path) -> std::io::Result> { - Ok(Box::new(Cursor::new(b"all:\n\techo ok\n"))) - } -} - -struct OpenFailureSourceReader; - -impl SourceReader for OpenFailureSourceReader { - fn open(&self, _path: &Utf8Path) -> std::io::Result> { - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "denied by test capability", - )) - } -} - -struct ReadFailureSourceReader; - -impl SourceReader for ReadFailureSourceReader { - fn open(&self, _path: &Utf8Path) -> std::io::Result> { - Ok(Box::new(FailingReader)) - } -} - -struct FailingReader; - -impl Read for FailingReader { - fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "broken source stream", - )) - } -} - #[rstest] fn injected_reader_supplies_source_bytes() -> googletest::Result<()> { - let bytes = read_path(&MemorySourceReader, Utf8Path::new("Makefile"))?; + let mut reader = MockSourceReader::new(); + reader + .expect_open() + .returning(|_| Ok(Box::new(Cursor::new(b"all:\n\techo ok\n")))); + let bytes = read_path(&reader, Utf8Path::new("Makefile"))?; verify_that!(bytes, eq(b"all:\n\techo ok\n")) } #[rstest] fn open_failures_keep_the_stable_operation() -> googletest::Result<()> { - let error = read_path(&OpenFailureSourceReader, Utf8Path::new("Makefile")) - .expect_err("opening should fail"); + let mut reader = MockSourceReader::new(); + reader.expect_open().returning(|_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied by test capability", + )) + }); + let error = read_path(&reader, Utf8Path::new("Makefile")).expect_err("opening should fail"); verify_that!(error.operation(), eq("source-open")) } #[rstest] fn read_failures_keep_the_stable_operation() -> googletest::Result<()> { - let error = read_path(&ReadFailureSourceReader, Utf8Path::new("Makefile")) - .expect_err("reading should fail"); + let mut reader = MockSourceReader::new(); + reader.expect_open().returning(|_| Ok(failing_reader())); + let error = read_path(&reader, Utf8Path::new("Makefile")).expect_err("reading should fail"); verify_that!(error.operation(), eq("source-read")) } From 2325eda1860c37ebe83c31fd8320c02f7e889b79 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 16 Jul 2026 11:57:17 +0100 Subject: [PATCH 11/29] Tag Clap invocation failures as CLI errors Route stderr-classified Clap failures through the stable `cli` fatal boundary while preserving the rendered command detail. Keep help and version as raw stdout display requests with successful exit status. Add black-box regressions for failure prefixes, retained usage text, help, version, stream separation, and non-JSON display output. --- src/adapters/cli.rs | 14 ++++++-------- tests/cli_e2e.rs | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 8163fef..ca79680 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -139,14 +139,12 @@ where } fn render_clap_error(error: &clap::Error, streams: &mut ProcessCapabilities<'_>) -> ProcessOutcome { - let exit_code = if error.use_stderr() { 2 } else { 0 }; - let writer = if error.use_stderr() { - &mut streams.stderr - } else { - &mut streams.stdout - }; - let _write_result = writer.write_all(error.to_string().as_bytes()); - ProcessOutcome { exit_code } + let rendered = error.to_string(); + if error.use_stderr() { + return fatal(streams.stderr, "cli", rendered.trim_end()); + } + let _write_result = streams.stdout.write_all(rendered.as_bytes()); + ProcessOutcome { exit_code: 0 } } fn run_parse( diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs index f75adff..c78b413 100644 --- a/tests/cli_e2e.rs +++ b/tests/cli_e2e.rs @@ -51,15 +51,45 @@ fn recovered_path_exits_one_with_json(mut makeutil_command: Command) { } #[rstest] -#[case(&["parse", "-"][..])] -#[case(&["parse"][..])] -fn invalid_invocation_exits_two(mut makeutil_command: Command, #[case] arguments: &[&str]) { +#[case(&["parse", "-"][..], "--stdin-filename")] +#[case(&["parse"][..], "Usage:")] +fn invalid_invocation_exits_two( + mut makeutil_command: Command, + #[case] arguments: &[&str], + #[case] expected_detail: &str, +) { let output = makeutil_command .args(arguments) .output() .expect("binary should run"); assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); + assert!(output.stderr.starts_with(b"makeutil: cli:")); + assert!(String::from_utf8_lossy(&output.stderr).contains(expected_detail)); +} + +#[rstest] +fn help_uses_clap_display_stream(mut makeutil_command: Command) { + let output = makeutil_command + .arg("--help") + .output() + .expect("binary should run"); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + assert!(serde_json::from_slice::(&output.stdout).is_err()); +} + +#[rstest] +fn version_uses_clap_display_stream(mut makeutil_command: Command) { + let output = makeutil_command + .arg("--version") + .output() + .expect("binary should run"); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + assert!(String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION"))); + assert!(serde_json::from_slice::(&output.stdout).is_err()); } #[rstest] From 33229baa70c5aedb882c02c672303575fc775ffc Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 16 Jul 2026 11:57:39 +0100 Subject: [PATCH 12/29] Refresh generated spelling policy Synchronize `typos.toml` with the current shared Oxford spelling dictionary. --- typos.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/typos.toml b/typos.toml index fe903c4..c1b282a 100644 --- a/typos.toml +++ b/typos.toml @@ -1703,6 +1703,24 @@ extend-ignore-re = [ "pluralizers" = "pluralizers" "pluralizes" = "pluralizes" "pluralizing" = "pluralizing" +"polymerisable" = "polymerizable" +"polymerisation" = "polymerization" +"polymerisations" = "polymerizations" +"polymerise" = "polymerize" +"polymerised" = "polymerized" +"polymeriser" = "polymerizer" +"polymerisers" = "polymerizers" +"polymerises" = "polymerizes" +"polymerising" = "polymerizing" +"polymerizable" = "polymerizable" +"polymerization" = "polymerization" +"polymerizations" = "polymerizations" +"polymerize" = "polymerize" +"polymerized" = "polymerized" +"polymerizer" = "polymerizer" +"polymerizers" = "polymerizers" +"polymerizes" = "polymerizes" +"polymerizing" = "polymerizing" "popularisable" = "popularizable" "popularisation" = "popularization" "popularisations" = "popularizations" From 2d29ad18a499565340cd3a2311c5a909cf52ddb0 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 16 Jul 2026 13:15:43 +0100 Subject: [PATCH 13/29] Synchronize ADR review contracts Record ADR-0001's accepted status consistently and repair the imported GPUI state example so its field accesses use the documented state type. Exercise invalid invocation, help, and version through the behavioural CLI seam, and share the all-facts report setup between schema tests. --- docs/contents.md | 2 +- .../adr-0001-single-file-gnu-make-parse.md | 7 +++ docs/repository-layout.md | 4 +- docs/rstest-bdd-users-guide.md | 21 ++++++++- tests/features/parse.feature | 21 +++++++++ tests/parse_bdd.rs | 43 +++++++++++++++++++ tests/report_schema.rs | 38 ++++++++++------ 7 files changed, 117 insertions(+), 19 deletions(-) diff --git a/docs/contents.md b/docs/contents.md index 7f2157f..c48eb92 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -24,7 +24,7 @@ set. - [Technical design](design.md) describes the command contract, data model, architecture, security boundaries, and verification strategy. - [ADR-0001: Parse one GNU Makefile into versioned JSON facts](adrs/0001-single-file-gnu-make-parse.md) - records the proposed boundary for the first implementation slice. + records the first-slice boundary, accepted on 2026-07-13. - [Execution plans](execplans/) describe approved, milestone-oriented delivery work: - [Implement ADR-0001](execplans/adr-0001-single-file-gnu-make-parse.md) diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index e28ec70..eab700f 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -223,6 +223,13 @@ stop and resolve the conflict before editing `Cargo.toml`. Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. All targets compiled with warnings denied and no unused test helper. +- [x] (2026-07-16) Reconciled ADR-0001's accepted date in the documentation + index and repository layout and made the imported GPUI reset snippet's hidden + state type match its field accesses. Added behavioural scenarios for invalid + invocation, help, and version, and shared the all-facts report fixture. The + scrutineer independently confirmed 54 of 54 tests, two passing doctests with + one intentionally ignored, and clean formatting, Polonius type-checking, + rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 342da04..271224d 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -75,8 +75,8 @@ compact and omits build output such as `target/`. - `docs/`: Holds long-lived reference documentation, guides, style rules, and design material. - `docs/adrs/`: Holds sequential, stable records of architectural decisions. -- `docs/adrs/0001-single-file-gnu-make-parse.md`: Records the proposed boundary - for parsing one GNU Makefile into versioned JSON facts. +- `docs/adrs/0001-single-file-gnu-make-parse.md`: Records the boundary, accepted + on 2026-07-13, for parsing one GNU Makefile into versioned JSON facts. - `docs/execplans/`: Holds living, approval-gated implementation plans. - `docs/execplans/adr-0001-single-file-gnu-make-parse.md`: Plans the staged, test-first implementation of ADR-0001. diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 1ac1dd5..123b3c9 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1270,8 +1270,25 @@ suite encodes: ```rust,no_run # use rstest_bdd_macros::given; -# fn reset_state_before_assignment() {} -# fn with_state(_: impl FnOnce(&mut ()) -> R) -> R { unimplemented!() } +# use std::cell::RefCell; +# #[derive(Clone, Debug, Default)] +# struct CounterView { value: usize } +# #[derive(Default)] +# struct ScenarioState { +# entity: Option>, +# window: Option, +# opened_window_count: usize, +# } +# thread_local! { +# static SCENARIO_STATE: RefCell = +# RefCell::new(ScenarioState::default()); +# } +# fn reset_state_before_assignment() { +# SCENARIO_STATE.with(|state| *state.borrow_mut() = ScenarioState::default()); +# } +# fn with_state(operation: impl FnOnce(&mut ScenarioState) -> R) -> R { +# SCENARIO_STATE.with(|state| operation(&mut state.borrow_mut())) +# } #[given("a fresh GPUI window is opened")] fn fresh_gpui_window_is_opened( #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext, diff --git a/tests/features/parse.feature b/tests/features/parse.feature index 8766ab3..b3f16fc 100644 --- a/tests/features/parse.feature +++ b/tests/features/parse.feature @@ -19,3 +19,24 @@ Feature: Parse one GNU Makefile into JSON facts Then stdout is empty And stderr reports the source-open operation And the process exits with code 2 + + Scenario: Reject an invalid invocation + Given an invalid parse invocation + When makeutil processes the invocation + Then stdout is empty + And stderr reports the cli operation + And the process exits with code 2 + + Scenario: Display help + Given a help display request + When makeutil processes the invocation + Then stdout contains command help + And stderr is empty + And the process exits with code 0 + + Scenario: Display version + Given a version display request + When makeutil processes the invocation + Then stdout contains the makeutil version + And stderr is empty + And the process exits with code 0 diff --git a/tests/parse_bdd.rs b/tests/parse_bdd.rs index e5b3099..319bf7b 100644 --- a/tests/parse_bdd.rs +++ b/tests/parse_bdd.rs @@ -51,8 +51,24 @@ fn missing_path(world: &mut World) { ]; } +#[given("an invalid parse invocation")] +fn invalid_invocation(world: &mut World) { + world.arguments = vec!["makeutil".to_owned(), "parse".to_owned(), "-".to_owned()]; +} + +#[given("a help display request")] +fn help_request(world: &mut World) { + world.arguments = vec!["makeutil".to_owned(), "--help".to_owned()]; +} + +#[given("a version display request")] +fn version_request(world: &mut World) { + world.arguments = vec!["makeutil".to_owned(), "--version".to_owned()]; +} + #[when("makeutil parses the fixture by path")] #[when("makeutil attempts to parse the missing path")] +#[when("makeutil processes the invocation")] fn run_path(world: &mut World) { run_world(world); } #[when("makeutil parses dash with stdin filename Makefile")] @@ -111,6 +127,21 @@ fn source_open(world: &World) { assert!(String::from_utf8_lossy(&world.stderr).contains("makeutil: source-open:")); } +#[then("stderr reports the cli operation")] +fn cli_error(world: &World) { + assert!(String::from_utf8_lossy(&world.stderr).contains("makeutil: cli:")); +} + +#[then("stdout contains command help")] +fn command_help(world: &World) { + assert!(String::from_utf8_lossy(&world.stdout).contains("Usage:")); +} + +#[then("stdout contains the makeutil version")] +fn command_version(world: &World) { + assert!(String::from_utf8_lossy(&world.stdout).contains(env!("CARGO_PKG_VERSION"))); +} + fn run_world(world: &mut World) { let mut source_reader = MockSourceReader::new(); source_reader.expect_open().returning(|path| { @@ -152,3 +183,15 @@ fn parse_stdin(_world: World) {} name = "Reject a missing input path" )] fn reject_missing(_world: World) {} + +#[scenario( + path = "tests/features/parse.feature", + name = "Reject an invalid invocation" +)] +fn reject_invalid_invocation(_world: World) {} + +#[scenario(path = "tests/features/parse.feature", name = "Display help")] +fn display_help(_world: World) {} + +#[scenario(path = "tests/features/parse.feature", name = "Display version")] +fn display_version(_world: World) {} diff --git a/tests/report_schema.rs b/tests/report_schema.rs index 36939ad..10184be 100644 --- a/tests/report_schema.rs +++ b/tests/report_schema.rs @@ -1,8 +1,13 @@ //! JSON Schema and snapshot tests for the stable report contract. -use makeutil::{adapters::MakefileLosslessParser, parse_source}; +use makeutil::{ + ParseApplicationError, + adapters::MakefileLosslessParser, + domain::ParseReport, + parse_source, +}; use pretty_assertions::assert_eq; -use rstest::rstest; +use rstest::{fixture, rstest}; #[derive(Debug, serde::Deserialize)] struct ConsumerReport { @@ -25,6 +30,15 @@ fn schema() -> Result { serde_json::from_str(include_str!("../schemas/makeutil.parse.v1.schema.json")) } +#[fixture] +fn all_facts_report() -> Result { + parse_source( + include_bytes!("fixtures/makefiles/all-facts.mk"), + "Makefile", + &MakefileLosslessParser, + ) +} + #[rstest] #[case(include_bytes!("fixtures/makefiles/all-facts.mk"), "complete.mk")] #[case(include_bytes!("fixtures/makefiles/recovered.mk"), "recovered.mk")] @@ -54,12 +68,10 @@ fn malformed_near_miss_is_rejected() -> Result<(), Box> { } #[rstest] -fn independent_consumer_deserializes_schema_v1() -> Result<(), Box> { - let report = parse_source( - include_bytes!("fixtures/makefiles/all-facts.mk"), - "Makefile", - &MakefileLosslessParser, - )?; +fn independent_consumer_deserializes_schema_v1( + all_facts_report: Result, +) -> Result<(), Box> { + let report = all_facts_report?; let document = serde_json::to_vec(&report)?; let consumer: ConsumerReport = serde_json::from_slice(&document)?; @@ -77,12 +89,10 @@ fn independent_consumer_deserializes_schema_v1() -> Result<(), Box Result<(), Box> { - let report = parse_source( - include_bytes!("fixtures/makefiles/all-facts.mk"), - "Makefile", - &MakefileLosslessParser, - )?; +fn all_fact_variants_have_stable_json( + all_facts_report: Result, +) -> Result<(), Box> { + let report = all_facts_report?; insta::assert_json_snapshot!(report); Ok(()) } From 91a6106550501216fde7e9068d3e2ed280cc11ca Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 16 Jul 2026 13:19:48 +0100 Subject: [PATCH 14/29] Test parser invariant failures directly Exercise the concrete adapter's round-trip mismatch guard and both `LocationError` branches so invalid spans and UTF-8 boundaries cannot regress without a focused failure. Document the private invariant helper's adapter-only scope and record the completed validation evidence in the ExecPlan. --- docs/developers-guide.md | 5 +++ .../adr-0001-single-file-gnu-make-parse.md | 6 +++ src/adapters/makefile.rs | 25 ++++++++++-- src/domain/location.rs | 38 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 90801a3..b67bfc1 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,6 +19,11 @@ external capability requires another port. CLI path and stdin filename values must continue to use OrthoConfig's explicit `ArgMatches` extraction, without file or environment layers. +The private `ensure_round_trip` helper owns the concrete adapter's +byte-for-byte CST invariant. It may be called only by +`MakefileLosslessParser::parse`; it is not a domain policy, parser port, or +general text-comparison utility. + `SourceReader` is the source adapter's narrow capability interface for opening one requested UTF-8 path. `read_path` owns complete byte collection and stable `SourceReadError` classification, but must never call `ambient_authority` diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index eab700f..de6f050 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -230,6 +230,12 @@ stop and resolve the conflict before editing `Cargo.toml`. scrutineer independently confirmed 54 of 54 tests, two passing doctests with one intentionally ignored, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. +- [x] (2026-07-16) Added direct regression coverage for the concrete parser's + round-trip mismatch guard and for invalid-span and split-UTF-8-boundary + `LocationError` paths. Terminal validation passed 59 of 59 tests, two + doctests with one intentionally ignored, and clean formatting, Polonius + type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and + diff checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index 7782ce0..bc4f264 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -32,9 +32,7 @@ impl MakefileParser for MakefileLosslessParser { fn parse(&self, source: &str) -> Result { let parsed = Parse::::parse_makefile(source); let tree = parsed.tree(); - if tree.to_string() != source { - return Err(ParserPortError::RoundTripMismatch); - } + ensure_round_trip(&tree, source)?; let mut observations = Vec::new(); collect_items(tree.items(), &[], source.len(), &mut observations)?; @@ -43,6 +41,14 @@ impl MakefileParser for MakefileLosslessParser { } } +fn ensure_round_trip(tree: &Makefile, source: &str) -> Result<(), ParserPortError> { + if tree.to_string() == source { + Ok(()) + } else { + Err(ParserPortError::RoundTripMismatch) + } +} + fn collect_items( items: impl Iterator, conditions: &[ConditionObservation], @@ -299,10 +305,11 @@ fn span( mod tests { //! Adapter invariant tests for unsupported upstream syntax. + use makefile_lossless::{Makefile, Parse}; use pretty_assertions::assert_eq; use rstest::rstest; - use super::condition_kind; + use super::{condition_kind, ensure_round_trip}; use crate::ports::ParserPortError; #[rstest] @@ -314,4 +321,14 @@ mod tests { }) ); } + + #[rstest] + fn round_trip_mismatch_is_rejected() { + let parsed = Parse::::parse_makefile("all:\n"); + + assert_eq!( + ensure_round_trip(&parsed.tree(), "different:\n"), + Err(ParserPortError::RoundTripMismatch) + ); + } } diff --git a/src/domain/location.rs b/src/domain/location.rs index 7c14628..7b9f942 100644 --- a/src/domain/location.rs +++ b/src/domain/location.rs @@ -136,3 +136,41 @@ impl<'source> LocationIndex<'source> { (line_index + 1, offset - line_start + 1) } } + +#[cfg(test)] +mod tests { + //! Regression tests for rejected source-location boundaries. + + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::{LocationError, LocationIndex, SourceSpan}; + + #[rstest] + #[case::reversed(3, 2, 3)] + #[case::end_out_of_bounds(0, 4, 3)] + fn invalid_spans_are_rejected( + #[case] start: usize, + #[case] end: usize, + #[case] source_length: usize, + ) { + assert_eq!( + SourceSpan::new(start, end, source_length), + Err(LocationError::InvalidSpan { + start, + end, + source_length, + }) + ); + } + + #[rstest] + #[case::start_boundary(SourceSpan { start: 1, end: 2 })] + #[case::end_boundary(SourceSpan { start: 0, end: 1 })] + fn split_utf8_boundaries_are_rejected(#[case] span: SourceSpan) { + assert_eq!( + LocationIndex::new("é").locate(span), + Err(LocationError::NonUtf8Boundary { offset: 1 }) + ); + } +} From c7102c4f1e67b6f51d86043f95a857a216512e46 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 16 Jul 2026 22:53:00 +0100 Subject: [PATCH 15/29] Close parse report schema invariants Represent assignment operators with a shared closed domain type and reject missing or unsupported upstream values before report serialization. Bind parse status to diagnostic cardinality in schema v1, add regression coverage for both contradictory states, and correct the imported OrthoConfig merge guidance and executable example. --- docs/design.md | 11 +++- docs/developers-guide.md | 7 +++ .../adr-0001-single-file-gnu-make-parse.md | 16 +++++ docs/ortho-config-users-guide.md | 36 ++++++----- schemas/makeutil.parse.v1.schema.json | 12 +++- src/adapters/makefile.rs | 61 +++++++++++++++++-- src/application.rs | 3 +- src/domain/mod.rs | 48 ++++++++++++++- src/ports.rs | 16 ++++- tests/domain_contract.rs | 35 ++++++++--- tests/report_schema.rs | 40 ++++++++++++ 11 files changed, 249 insertions(+), 36 deletions(-) diff --git a/docs/design.md b/docs/design.md index f0bfc4c..ef2ffe1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -168,7 +168,10 @@ Schema version 1 has this shape: } ``` -`parse.status` is either `complete` or `recovered`. +`parse.status` is either `complete` or `recovered`. A complete report has no +diagnostics; a recovered report has at least one diagnostic. Schema-v1 +producers must not emit a status and diagnostics collection that violate this +invariant. The schema does not include the complete source text or CST. The caller already owns the source, and duplicating it would enlarge policy input without adding @@ -310,8 +313,10 @@ for the first bounded rules. } ``` -The operator remains source-faithful. The first slice does not calculate the -effective value or precedence. +The schema-v1 operator set is closed: `""`, `"="`, `":="`, `"::="`, `":::="`, +`"+="`, `"?="`, and `"!="`. The empty string means a `define` block without an +assignment token. The operator remains source-faithful; the first slice does +not calculate the effective value or precedence. ### 6.7. Include facts diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b67bfc1..a0f2280 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -46,6 +46,13 @@ them, and do not suppress the resulting unused-code warnings. enum only when the supported GNU Make contract adds another directive, and do not pass upstream strings beyond the adapter. +`AssignmentOperator` is the shared, closed domain and parser-port +representation for schema-v1 variable operators. The parser adapter is its only +producer; `SyntaxObservation` and report types are its permitted consumers. Its +`Define` variant serializes as an empty string and means a `define` block +without an assignment token. Extend the enum only through a schema-versioned +contract decision, and do not pass upstream operator strings beyond the adapter. + The makefile adapter privately scans leading recipe modifiers. This scanner exists because the upstream API has no always-execute accessor and its silent and ignore-error accessors are sensitive to modifier order. It may be called diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index de6f050..98ec9ce 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -236,6 +236,11 @@ stop and resolve the conflict before editing `Cargo.toml`. doctests with one intentionally ignored, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. +- [x] (2026-07-16) Closed the `AssignmentOperator` contract and enforced the + status/diagnostics schema invariant. The scrutineer independently confirmed + 72 of 72 tests, three passing doctests with one intentionally ignored, + unchanged snapshots, and clean formatting, Polonius type-checking, rustdoc, + Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. @@ -310,6 +315,11 @@ stop and resolve the conflict before editing `Cargo.toml`. observability surface, success and recovered parsing keep stderr empty, and the design explicitly defers metrics. Impact: no subscriber, recorder, or new telemetry dependency was added during terminal review. +- Observation: schema v1 already enumerated assignment-operator strings, while + the runtime carried an unrestricted string from the upstream adapter. Impact: + an upstream value outside the schema could be serialized into a report that + the checked schema rejected, so the runtime boundary must enforce the same + closed set. ## Decision log @@ -391,6 +401,12 @@ stop and resolve the conflict before editing `Cargo.toml`. ports for implementation details. Permitted call sites and reuse policy are recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Wyvern review team. +- Decision: represent schema-v1 assignment operators with the closed, + domain-owned `AssignmentOperator` enum shared by the parser port and report + model. The empty representation is reserved for a `define` block without an + assignment token. Rationale: the producer must reject upstream drift before + serialization rather than emit JSON outside the checked schema. Date/Author: + 2026-07-16 / Wyvern review team. - Decision: define `SourceReader` in the source adapter as a narrow capability interface, not a domain port. `read_path` owns byte collection and `SourceReadError` classification; `run_from` alone constructs the diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index c3da6a7..571ccab 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -113,7 +113,7 @@ its layers to the derived helper to build the final struct: ```rust use ortho_config::{MergeComposer, OrthoConfig}; -use ortho_config::json; +use ortho_config::serde_json::json; use serde::Deserialize; #[derive(Debug, Deserialize, OrthoConfig)] @@ -122,17 +122,23 @@ struct AppConfig { salutations: Vec, } -let mut composer = MergeComposer::new(); -composer.push_defaults(json!({"recipient": "Defaults", "salutations": ["Hi"] })); -composer.push_environment(json!({"salutations": ["Env"] })); -composer.push_cli(json!({"recipient": "Cli" })); - -let merged = AppConfig::merge_from_layers(composer.layers())?; -assert_eq!(merged.recipient, "Cli"); -assert_eq!( - merged.salutations, - vec![String::from("Hi"), String::from("Env")] -); +fn demo() -> ortho_config::OrthoResult<()> { + let mut composer = MergeComposer::new(); + composer.push_defaults(json!({ + "recipient": "Defaults", + "salutations": ["Hi"] + })); + composer.push_environment(json!({"salutations": ["Env"] })); + composer.push_cli(json!({"recipient": "Cli" })); + + let merged = AppConfig::merge_from_layers(composer.layers())?; + assert_eq!(merged.recipient, "Cli"); + assert_eq!( + merged.salutations, + vec![String::from("Hi"), String::from("Env")] + ); + Ok(()) +} ``` This API surfaces the same precedence as the generated `load()` method while @@ -616,9 +622,9 @@ following steps: 4. Adds a provider containing the CLI values (captured as `Option` fields) as the final layer. -5. Merges vector fields according to the `merge_strategy` (currently only - `append`) so that lists of values from lower precedence sources are extended - with values from higher precedence ones. +5. Merges vector fields according to `merge_strategy`: `append` extends values + from lower-precedence sources with higher-precedence values, while `replace` + discards the lower-precedence vector when a later layer supplies one. 6. Attempts to extract the merged configuration into the concrete struct. On success it returns the completed configuration; otherwise an `OrthoError` is diff --git a/schemas/makeutil.parse.v1.schema.json b/schemas/makeutil.parse.v1.schema.json index 9bbbe90..fbcb029 100644 --- a/schemas/makeutil.parse.v1.schema.json +++ b/schemas/makeutil.parse.v1.schema.json @@ -43,7 +43,17 @@ "properties": { "status": { "enum": ["complete", "recovered"] }, "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } } - } + }, + "allOf": [ + { + "if": { "required": ["status"], "properties": { "status": { "const": "complete" } } }, + "then": { "properties": { "diagnostics": { "maxItems": 0 } } } + }, + { + "if": { "required": ["status"], "properties": { "status": { "const": "recovered" } } }, + "then": { "properties": { "diagnostics": { "minItems": 1 } } } + } + ] }, "location": { "type": "object", diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index bc4f264..127a9f5 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -13,7 +13,7 @@ use makefile_lossless::{ use rowan::ast::AstNode as _; use crate::{ - domain::{ConditionBranch, ConditionKind, SourceSpan}, + domain::{AssignmentOperator, ConditionBranch, ConditionKind, SourceSpan}, ports::{ ConditionObservation, MakefileParser, @@ -113,7 +113,10 @@ fn variable_observation( name: variable.name().ok_or(ParserPortError::MissingField { field: "variable-name", })?, - operator: variable.assignment_operator().unwrap_or_default(), + operator: assignment_operator( + variable.assignment_operator().as_deref(), + variable.is_define(), + )?, raw_value: variable.raw_value().unwrap_or_default(), exported: variable.is_export(), overridden: variable.is_override(), @@ -123,6 +126,28 @@ fn variable_observation( }) } +fn assignment_operator( + operator: Option<&str>, + is_define: bool, +) -> Result { + match operator { + None if is_define => Ok(AssignmentOperator::Define), + Some("=") => Ok(AssignmentOperator::Recursive), + Some(":=") => Ok(AssignmentOperator::Simple), + Some("::=") => Ok(AssignmentOperator::PosixSimple), + Some(":::=") => Ok(AssignmentOperator::ImmediateRecursive), + Some("+=") => Ok(AssignmentOperator::Append), + Some("?=") => Ok(AssignmentOperator::Conditional), + Some("!=") => Ok(AssignmentOperator::Shell), + Some(raw_operator) => Err(ParserPortError::UnsupportedAssignmentOperator { + operator: raw_operator.to_owned(), + }), + None => Err(ParserPortError::MissingField { + field: "variable-assignment-operator", + }), + } +} + fn include_observation( include: &Include, conditions: &[ConditionObservation], @@ -309,8 +334,8 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; - use super::{condition_kind, ensure_round_trip}; - use crate::ports::ParserPortError; + use super::{assignment_operator, condition_kind, ensure_round_trip}; + use crate::{domain::AssignmentOperator, ports::ParserPortError}; #[rstest] fn unknown_condition_kind_is_rejected() { @@ -331,4 +356,32 @@ mod tests { Err(ParserPortError::RoundTripMismatch) ); } + + #[rstest] + fn define_without_operator_uses_empty_schema_variant() { + assert_eq!( + assignment_operator(None, true), + Ok(AssignmentOperator::Define) + ); + } + + #[rstest] + fn ordinary_variable_requires_an_operator() { + assert_eq!( + assignment_operator(None, false), + Err(ParserPortError::MissingField { + field: "variable-assignment-operator", + }) + ); + } + + #[rstest] + fn unsupported_assignment_operator_is_rejected() { + assert_eq!( + assignment_operator(Some("unknown"), false), + Err(ParserPortError::UnsupportedAssignmentOperator { + operator: "unknown".to_owned(), + }) + ); + } } diff --git a/src/application.rs b/src/application.rs index 36164a1..75e3a83 100644 --- a/src/application.rs +++ b/src/application.rs @@ -5,6 +5,7 @@ use thiserror::Error; use crate::{ domain::{ + AssignmentOperator, ConditionContext, IncludeFact, LocationError, @@ -265,7 +266,7 @@ struct RuleParts { struct VariableParts { name: String, - operator: String, + operator: AssignmentOperator, raw_value: String, exported: bool, overridden: bool, diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 534022d..c64b4ef 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -112,6 +112,52 @@ pub enum ConditionKind { Ifneq, } +/// GNU Make variable assignment operator represented by schema version 1. +/// +/// `Define` represents a define block without an assignment token and serializes +/// as the schema's empty operator. +/// +/// # Examples +/// +/// ``` +/// use makeutil::domain::AssignmentOperator; +/// +/// assert_eq!( +/// serde_json::to_string(&AssignmentOperator::Shell)?, +/// r#""!=""# +/// ); +/// assert_eq!(serde_json::to_string(&AssignmentOperator::Define)?, r#""""#); +/// # Ok::<(), serde_json::Error>(()) +/// ``` +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub enum AssignmentOperator { + /// Define block without an assignment token. + #[default] + #[serde(rename = "")] + Define, + /// Recursively expanded assignment (`=`). + #[serde(rename = "=")] + Recursive, + /// Simply expanded assignment (`:=`). + #[serde(rename = ":=")] + Simple, + /// POSIX-style simply expanded assignment (`::=`). + #[serde(rename = "::=")] + PosixSimple, + /// Immediately expanded recursive assignment (`:::=`). + #[serde(rename = ":::=")] + ImmediateRecursive, + /// Appending assignment (`+=`). + #[serde(rename = "+=")] + Append, + /// Conditional assignment (`?=`). + #[serde(rename = "?=")] + Conditional, + /// Shell assignment (`!=`). + #[serde(rename = "!=")] + Shell, +} + /// Branch of a conditional. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -166,7 +212,7 @@ pub struct VariableFact { /// Variable name. pub name: String, /// Source assignment operator. - pub operator: String, + pub operator: AssignmentOperator, /// Unexpanded source value. pub raw_value: String, /// Whether the `export` modifier is present. diff --git a/src/ports.rs b/src/ports.rs index 756b400..04bbbe8 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -2,7 +2,13 @@ use thiserror::Error; -use crate::domain::{ConditionBranch, ConditionKind, LocationError, SourceSpan}; +use crate::domain::{ + AssignmentOperator, + ConditionBranch, + ConditionKind, + LocationError, + SourceSpan, +}; /// Parser output expressed only in makeutil-owned observations. #[derive(Debug, Clone, PartialEq, Eq)] @@ -62,7 +68,7 @@ pub enum SyntaxObservation { /// Variable name. name: String, /// Assignment operator. - operator: String, + operator: AssignmentOperator, /// Unexpanded value. raw_value: String, /// Whether exported. @@ -119,6 +125,12 @@ pub enum ParserPortError { /// Upstream keyword that could not be represented by the domain enum. kind: String, }, + /// An upstream assignment operator was outside the schema-v1 set. + #[error("unsupported assignment operator {operator}")] + UnsupportedAssignmentOperator { + /// Upstream operator that could not be represented by the domain enum. + operator: String, + }, } /// Parses source without exposing upstream parser types. diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 9c9a122..70c3a81 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -2,7 +2,7 @@ use makeutil::{ adapters::MakefileLosslessParser, - domain::{ConditionKind, LocationIndex, ParseStatus, SourceSpan}, + domain::{AssignmentOperator, ConditionKind, LocationIndex, ParseStatus, SourceSpan}, parse_source, }; use pretty_assertions::assert_eq; @@ -106,16 +106,16 @@ fn recovered_parse_retains_facts_and_diagnostics() { } #[rstest] -#[case("A = one\n", "=", "one")] -#[case("A := two\n", ":=", "two")] -#[case("A ::= three\n", "::=", "three")] -#[case("A :::= four\n", ":::=", "four")] -#[case("A += five\n", "+=", "five")] -#[case("A ?= six\n", "?=", "six")] -#[case("A != printf seven\n", "!=", "printf seven")] +#[case("A = one\n", AssignmentOperator::Recursive, "one")] +#[case("A := two\n", AssignmentOperator::Simple, "two")] +#[case("A ::= three\n", AssignmentOperator::PosixSimple, "three")] +#[case("A :::= four\n", AssignmentOperator::ImmediateRecursive, "four")] +#[case("A += five\n", AssignmentOperator::Append, "five")] +#[case("A ?= six\n", AssignmentOperator::Conditional, "six")] +#[case("A != printf seven\n", AssignmentOperator::Shell, "printf seven")] fn assignment_operators_remain_source_faithful( #[case] source: &str, - #[case] operator: &str, + #[case] operator: AssignmentOperator, #[case] raw_value: &str, ) { let report = parse_source(source.as_bytes(), "Makefile", &MakefileLosslessParser) @@ -128,6 +128,23 @@ fn assignment_operators_remain_source_faithful( assert_eq!(variable.raw_value, raw_value); } +#[rstest] +#[case(AssignmentOperator::Define, r#""""#)] +#[case(AssignmentOperator::Recursive, r#""=""#)] +#[case(AssignmentOperator::Simple, r#"":=""#)] +#[case(AssignmentOperator::PosixSimple, r#""::=""#)] +#[case(AssignmentOperator::ImmediateRecursive, r#"":::=""#)] +#[case(AssignmentOperator::Append, r#""+=""#)] +#[case(AssignmentOperator::Conditional, r#""?=""#)] +#[case(AssignmentOperator::Shell, r#""!=""#)] +fn assignment_operators_match_schema_values( + #[case] operator: AssignmentOperator, + #[case] expected_json: &str, +) -> Result<(), serde_json::Error> { + assert_eq!(serde_json::to_string(&operator)?, expected_json); + Ok(()) +} + #[rstest] #[case("A = value \n", "value ")] #[case("A = value\t \n", "value\t ")] diff --git a/tests/report_schema.rs b/tests/report_schema.rs index 10184be..2419a11 100644 --- a/tests/report_schema.rs +++ b/tests/report_schema.rs @@ -67,6 +67,46 @@ fn malformed_near_miss_is_rejected() -> Result<(), Box> { } } +#[rstest] +#[case::complete_with_diagnostics(true)] +#[case::recovered_without_diagnostics(false)] +fn contradictory_parse_summaries_are_rejected( + #[case] complete_with_diagnostics: bool, +) -> Result<(), Box> { + let complete = parse_source( + include_bytes!("fixtures/makefiles/all-facts.mk"), + "Makefile", + &MakefileLosslessParser, + )?; + let recovered = parse_source( + include_bytes!("fixtures/makefiles/recovered.mk"), + "recovered.mk", + &MakefileLosslessParser, + )?; + let recovered_diagnostics = serde_json::to_value(&recovered.parse.diagnostics)?; + let mut document = serde_json::to_value(if complete_with_diagnostics { + complete + } else { + recovered + })?; + let diagnostics = if complete_with_diagnostics { + recovered_diagnostics + } else { + serde_json::json!([]) + }; + let diagnostics_slot = document + .pointer_mut("/parse/diagnostics") + .ok_or("serialized report should contain parse diagnostics")?; + *diagnostics_slot = diagnostics; + + let validator = jsonschema::validator_for(&schema()?)?; + if validator.is_valid(&document) { + Err("contradictory parse summary unexpectedly validated".into()) + } else { + Ok(()) + } +} + #[rstest] fn independent_consumer_deserializes_schema_v1( all_facts_report: Result, From 61f116ca3e3d5929dad5d9af09aedd0d9d291dbc Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 18 Jul 2026 15:55:15 +0100 Subject: [PATCH 16/29] Test multiline define assignments Exercise define syntax in the source-faithfulness matrix and assert both the empty assignment-operator variant and exact multiline body text. --- tests/domain_contract.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 70c3a81..f5cbf3e 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -113,6 +113,11 @@ fn recovered_parse_retains_facts_and_diagnostics() { #[case("A += five\n", AssignmentOperator::Append, "five")] #[case("A ?= six\n", AssignmentOperator::Conditional, "six")] #[case("A != printf seven\n", AssignmentOperator::Shell, "printf seven")] +#[case::define_block( + "define SCRIPT\necho one\necho two\nendef\n", + AssignmentOperator::Define, + "echo one\necho two\n" +)] fn assignment_operators_remain_source_faithful( #[case] source: &str, #[case] operator: AssignmentOperator, From 1f39b9125db1cc89fa7ac1edd944ed58f20fee08 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 18 Jul 2026 16:31:28 +0100 Subject: [PATCH 17/29] Harden parser input and CLI failure boundaries Bound path and standard-input reads, preserve stable diagnostics when clap display output fails, and close the exact-byte multiline `define` contract. Keep OrthoConfig JSON support behind makeutil's default feature, add focused regressions for the reviewed failure paths, and align the design, contributor, and user documentation with the implemented ownership and limits. --- .gitattributes | 2 + Cargo.toml | 2 +- docs/design.md | 35 ++--- docs/developers-guide.md | 17 ++- .../adr-0001-single-file-gnu-make-parse.md | 120 ++++++++++++++---- docs/ortho-config-users-guide.md | 2 +- docs/rstest-bdd-users-guide.md | 10 +- docs/users-guide.md | 5 + src/adapters/cli.rs | 8 +- src/adapters/makefile.rs | 60 +-------- src/adapters/makefile_tests.rs | 84 ++++++++++++ src/adapters/source.rs | 47 +++++-- tests/cli_e2e.rs | 4 + tests/domain_contract.rs | 4 +- tests/fixtures/makefiles/multiline-define.mk | 4 + tests/output_failures.rs | 60 ++++++++- tests/source_adapter.rs | 50 +++++++- 17 files changed, 392 insertions(+), 122 deletions(-) create mode 100644 .gitattributes create mode 100644 src/adapters/makefile_tests.rs create mode 100644 tests/fixtures/makefiles/multiline-define.mk diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..16f862f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve intentional trailing whitespace in the exact-byte parser fixture. +tests/fixtures/makefiles/multiline-define.mk -diff diff --git a/Cargo.toml b/Cargo.toml index c664fd1..36191ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ cap-std = { version = "4.0.2", features = ["fs_utf8"] } clap = { version = "4.5.54", features = ["derive"] } data-encoding = "2.10.0" makefile-lossless = "=0.3.40" -ortho_config = { version = "0.8.0", features = ["serde_json"] } +ortho_config = { version = "0.8.0", default-features = false, features = ["toml"] } rowan = "0.16.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/docs/design.md b/docs/design.md index ef2ffe1..1ce2392 100644 --- a/docs/design.md +++ b/docs/design.md @@ -370,12 +370,14 @@ The package may expose a Rust library internally for unit tests, but only the CLI and JSON schema form a supported integration contract in the first release. The domain owns report types, source spans and locations, conditional ancestry, -ordinal assignment, diagnostic ordering, the `SourceIdentity` contract, and -complete versus recovered classification. A domain-owned parser port accepts -UTF-8 text and returns ordered makeutil-owned syntax observations, source -spans, and diagnostics. The `makefile-lossless` adapter implements the port and -proves its own complete-tree round trip; it never returns Rowan nodes, upstream -errors, or rendered CST bytes through the port. +the global ordinal ordering invariant, diagnostic ordering, the +`SourceIdentity` contract, and complete versus recovered classification. +`parse_source` and its `ReportAssembly` fact collector assign ordinals while +assembling those domain types. A domain-owned parser port accepts UTF-8 text +and returns ordered makeutil-owned syntax observations, source spans, and +diagnostics. The `makefile-lossless` adapter implements the port and proves its +own complete-tree round trip; it never returns Rowan nodes, upstream errors, or +rendered CST bytes through the port. The application service calculates SHA-256 over the exact input bytes while `parse_source` constructs `SourceIdentity`. @@ -429,8 +431,10 @@ rules into an effective rule. - It does not follow includes or symlinks discovered from source. - It performs no network access. - Source text cannot select another parser, command, or output path. -- Resource limits may be added later if corpus evidence shows pathological - inputs; the first slice still includes large-file and deep-conditional tests. +- Path and standard-input sources are capped at an inclusive 16 MiB. The + reader probes at most one further byte before returning `source-too-large`. +- Large-file tests remain below that ceiling, and deep-conditional tests guard + parser behaviour independently of source size. The security suite uses source-selected filesystem sentinels for `$(shell ...)`, `$(file ...)`, `!=`, and recipes. It separately traces file-open system calls @@ -452,13 +456,14 @@ makeutil: : ``` Operation identifiers distinguish `cli`, `source-open`, `source-read`, -`source-utf8`, `parse-internal`, `json-serialize`, and `stdout-write`. Normal -success and recovered parsing emit no stderr. The detail includes the logical -path for `source-open` and `source-read` failures. Backtraces and cause chains -are not printed by default. The binary may install one tracing subscriber, but -it must never write tracing events to stdout; the library installs no -subscriber. Source contents and unbounded raw paths are not tracing fields. -This one-shot CLI emits no metrics in the first slice. +`source-too-large`, `source-utf8`, `parse-internal`, `json-serialize`, and +`stdout-write`. Normal success and recovered parsing emit no stderr. The detail +includes the logical path for `source-open`, `source-read`, and +`source-too-large` failures. Backtraces and cause chains are not printed by +default. The binary may install one tracing subscriber, but it must never write +tracing events to stdout; the library installs no subscriber. Source contents +and unbounded raw paths are not tracing fields. This one-shot CLI emits no +metrics in the first slice. ## 11. Verification strategy diff --git a/docs/developers-guide.md b/docs/developers-guide.md index a0f2280..0ad66df 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -34,6 +34,13 @@ embedded callers may instead use `run_from_with_reader`. Do not use general filesystem access, and do not promote it into the domain-owned parser port. +Path and standard-input collection share one private source-adapter +bounded-read helper. It accepts an inclusive 16 MiB and probes for one further +byte; excess input becomes `SourceReadError::TooLarge` and the stable +`source-too-large` operation. The helper may be called only by `read_path` and +`read_stdin`. It is not a domain port, a public stream utility, or permission +to add other input modes. + Integration tests share `MockSourceReader` from `tests/common/mod.rs`, where `mockall` remains a development-only dependency. Include `tests/common/failing_reader.rs` only in suites that exercise post-open read @@ -81,7 +88,15 @@ the complete assignment-operator contract matrix before updating the lockfile. Tests keep raw Makefile text under `tests/fixtures/makefiles/`. Unit and property tests exercise the domain, `rstest-bdd` scenarios exercise observable behaviour, black-box tests spawn the binary, and `insta` plus the JSON Schema -freeze the integration contract. +freeze the integration contract. The exact-byte multiline `define` fixture is +marked `-diff` in `.gitattributes` because its trailing whitespace is test +data; parser tests must continue to assert those bytes explicitly. + +Cargo's default `serde_json` feature forwards to `ortho_config/serde_json`. Keep +`ortho_config` configured with `default-features = false` so no-default builds +do not enable its JSON integration implicitly. The direct `serde_json` +dependency remains the report serialization implementation and is not a +substitute for forwarding the OrthoConfig feature. ## Local Workflow diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 98ec9ce..9856ca6 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -241,6 +241,25 @@ stop and resolve the conflict before editing `Cargo.toml`. 72 of 72 tests, three passing doctests with one intentionally ignored, unchanged snapshots, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. +- [x] (2026-07-18) Added one shared bounded-read implementation for path and + standard-input sources, accepting at most 16 MiB and reporting larger sources + through the stable `source-too-large` fatal operation. +- [x] (2026-07-18) Corrected Cargo feature wiring so makeutil's `serde_json` + feature forwards to OrthoConfig while OrthoConfig default features remain + disabled. The no-default feature graph contains only OrthoConfig's `toml` + feature; the upstream no-JSON compilation defect is recorded below. +- [x] (2026-07-18) Propagated clap help/version display write failures as + `stdout-write`, and added focused coverage for failed displays alongside the + existing successful black-box help and version cases. +- [x] (2026-07-18) Added a dedicated external multiline `define` fixture with + embedded newlines and trailing whitespace, and asserted its exact domain + representation through the concrete parser and domain contract suite. +- [x] (2026-07-18) The independent scrutineer confirmed 82 of 82 tests, three + passing doctests with one intentionally ignored, clean formatting, Polonius + type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and + diff checks. It also confirmed that the lockfile and `typos.toml` are + unchanged and that no-default feature resolution excludes + `ortho_config/serde_json`. - [ ] Obtain CodeRabbit certification of the exact terminal diff through the pull request. The user approved deferral from the unavailable CLI review during CodeRabbit's temporary outage. @@ -320,6 +339,26 @@ stop and resolve the conflict before editing `Cargo.toml`. an upstream value outside the schema could be serialized into a report that the checked schema rejected, so the runtime boundary must enforce the same closed set. +- Observation: path and standard-input readers collected unbounded input into + memory through separate implementations. Impact: both inputs need one private + bounded-read helper, an inclusive 16 MiB policy, and the stable + `source-too-large` fatal operation before parsing or JSON serialization. +- Observation: enabling OrthoConfig's `serde_json` feature directly on the + dependency defeats makeutil's declared feature boundary. Impact: disable + OrthoConfig default features and forward only makeutil's `serde_json` feature + to `ortho_config/serde_json`. +- Observation: published `ortho_config` 0.8.0 does not compile with its + `serde_json` feature disabled because unconditional modules import JSON-gated + symbols. Impact: makeutil's no-default dependency graph now correctly omits + that feature, but a no-default compile remains blocked upstream and must not + be misreported as local feature leakage. +- Observation: clap help and version rendering ignored standard-output write + failures. Impact: display output must use the same checked write semantics as + report output, with focused failure injection and black-box success tests. +- Observation: multiline `define` parsing and trailing assignment whitespace + had separate tests, but no external fixture combined the two properties. + Impact: a dedicated fixture must prove exact raw-body preservation through + the full parser and application assembly path. ## Decision log @@ -382,11 +421,34 @@ stop and resolve the conflict before editing `Cargo.toml`. 2026-07-13 / Logisphere-reviewed Codex planning team. - Decision: let the parser adapter return ordered makeutil-owned observations and source spans; keep round-trip bytes in adapter tests only. Rationale: - location conversion, ordinals, and status are domain policy; exact-byte - hashing is application-service policy. Upstream CST renderings and error - types must not leak through the domain-owned port. Date/Author: 2026-07-13 / - Logisphere-reviewed Codex planning team. Ownership wording clarified on - 2026-07-14 during terminal documentation review. + location conversion, the ordinal ordering invariant, and status are + makeutil-owned policy; `parse_source` and its `ReportAssembly` fact collector + assign ordinals, while exact-byte hashing is application-service policy. + Upstream CST renderings and error types must not leak through the + domain-owned port. Date/Author: 2026-07-13 / Logisphere-reviewed Codex + planning team. Ownership wording clarified on 2026-07-18 during terminal + documentation review. +- Decision: cap each path or standard-input source at an inclusive 16 MiB by + composing both adapters through one private bounded-read helper. The helper + is source-adapter implementation detail, not a port or general I/O utility. + Rationale: one policy prevents input-dependent memory growth and keeps error + classification identical across both input modes. Date/Author: 2026-07-18 / + Wyvern review team. +- Decision: treat clap display writes as process output subject to + `stdout-write`, while preserving clap's normal stream and exit-zero semantics + when the complete display is written. Rationale: help and version output are + externally observable process behaviour and cannot silently discard an I/O + failure. Date/Author: 2026-07-18 / Wyvern review team. +- Decision: make makeutil's `serde_json` feature the sole switch for + OrthoConfig's JSON integration and disable OrthoConfig default features. + Rationale: feature ownership stays visible at the application manifest, and + `--no-default-features` has predictable dependency behaviour. Date/Author: + 2026-07-18 / Wyvern review team. +- Decision: keep the multiline `define` regression as external Makefile input + and exercise it through the concrete parser and application service. + Rationale: the contract concerns exact source bytes across the adapter + boundary, so an inline domain-only case cannot prove it. Date/Author: + 2026-07-18 / Wyvern review team. - Decision: serialize to memory before stdout and permit partial stdout only when the operating system accepts a prefix before an output failure. Rationale: the process can prevent serialization failures from writing JSON, @@ -503,10 +565,11 @@ parse report ──> composition root ──> JSON reporter ──> stdout / pro ``` The domain owns schema-v1 value types, the `SourceIdentity` contract, source -locations, conditional ancestry, global ordinal assignment, diagnostic order, -and complete/recovered classification. The application service validates one -source byte buffer as UTF-8, calculates its exact-byte SHA-256 digest, and -coordinates its logical path with the parser port. Adapters own +locations, conditional ancestry, the global ordinal ordering invariant, +diagnostic order, and complete/recovered classification. The application +service validates one source byte buffer as UTF-8, calculates its exact-byte +SHA-256 digest, coordinates its logical path with the parser port, and assigns +ordinals through the `ReportAssembly` fact collector. Adapters own OrthoConfig/clap, capability-oriented file or stdin reading, upstream parsing into ordered observations, Serde serialization, streams, and process exit. Adapters never call each other; `src/main.rs` is the composition root. @@ -596,6 +659,12 @@ literal/optional/dynamic includes, empty input, no trailing newline, CRLF, multibyte UTF-8, recoverable syntax, large input, deep conditionals, and hostile text. +Include a dedicated multiline `define` fixture whose raw body contains embedded +newlines and trailing spaces or tabs. Through `parse_source` with the concrete +`MakefileLosslessParser`, assert the empty serialized +`AssignmentOperator::Define` representation and the exact untrimmed raw body, +including every embedded newline and trailing whitespace byte. + Use `googletest` matchers for membership, order, option, and error semantics and `pretty_assertions` for full structured fact comparisons. Add `insta` snapshots for at least one complete document, one recovered document with @@ -627,20 +696,23 @@ Freeze exit and error mapping before wiring: help and version display exit 0 using clap's normal display stream; usage errors, non-UTF-8 path arguments, missing `--stdin-filename`, and conflicting stdin options use `cli` and exit 2; open and read errors use `source-open` or `source-read` and exit 2; invalid -file bytes use `source-utf8` and exit 2; recovered parser diagnostics emit JSON -and exit 1; parser invariant failures use `parse-internal` and exit 2; -in-memory serialization uses `json-serialize` and exit 2; broken pipe or other -write failure uses `stdout-write` and exit 2. Panics are defects and are not -converted into stable diagnostics by a catch boundary. +file bytes use `source-utf8` and exit 2; input beyond the inclusive 16 MiB +limit uses `source-too-large` and exit 2 without JSON; recovered parser +diagnostics emit JSON and exit 1; parser invariant failures use +`parse-internal` and exit 2; in-memory serialization uses `json-serialize` and +exit 2; broken pipe or other write failure uses `stdout-write` and exit 2. +Panics are defects and are not converted into stable diagnostics by a catch +boundary. Implement capability-oriented path reading with `cap_std::fs_utf8` and -`camino`; keep a narrow stdin reader. Calculate SHA-256 over exact bytes, -reject invalid UTF-8 before parsing, and retain the caller-supplied logical -path without filesystem canonicalization. The JSON reporter writes one compact -document plus newline to stdout for complete and recovered results and writes -no progress prose. Fatal errors go to stderr. Serialization failures emit no -JSON; stdout-write failures may leave only the partial prefix described in -`docs/design.md` section 10.1. +`camino`; keep a narrow stdin reader. Compose both readers through one private +bounded-read helper that accepts exactly 16 MiB and rejects the next byte. +Calculate SHA-256 over exact bytes, reject invalid UTF-8 before parsing, and +retain the caller-supplied logical path without filesystem canonicalization. +The JSON reporter writes one compact document plus newline to stdout for +complete and recovered results and writes no progress prose. Fatal errors go to +stderr. Serialization failures emit no JSON; stdout-write failures may leave +only the partial prefix described in `docs/design.md` section 10.1. Add `tests/features/parse.feature` and Rust step bindings using `rstest-bdd` 0.6.0-beta3. Keep this specification synchronized with the tests: @@ -969,8 +1041,10 @@ Before adding each non-exception dependency, verify its current compatible caret version and smallest necessary feature set. Preserve the approved exact `makefile-lossless = "=0.3.40"` requirement unchanged. Resolve it through the temporary full-SHA fork patch recorded above until upstream contains the fix. -Do not add both a direct `clap` dependency and OrthoConfig's re-exported -surface unless the derive/API contract requires it. +The direct `clap` dependency is required for derive and display-error APIs. +Declare OrthoConfig with `default-features = false`; makeutil's default +`serde_json` feature forwards to `ortho_config/serde_json`, so no-default +builds do not enable that OrthoConfig integration implicitly. Planned development dependencies are `rstest = "0.26.1"`, `rstest-bdd = "0.6.0-beta3"`, `rstest-bdd-macros = "0.6.0-beta3"`, diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 571ccab..3c8ee3f 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -98,7 +98,7 @@ if let Some(figment) = discovery.load_first()? { # } ``` -The repository ships `config/overrides.toml`, which extends +The upstream OrthoConfig repository ships `config/overrides.toml`, which extends `config/baseline.toml` to set `is_excited = true`, provide a `Layered hello` preamble, and swap the greet punctuation for `!!!`. Behavioural tests and demo scripts assert the uppercase output to guard this layering. diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 123b3c9..7fa3ae9 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1327,7 +1327,8 @@ repository's pedantic lint profile: ```rust,no_run # use rstest_bdd_macros::{then, when}; -# fn current_handles() -> (gpui::Entity<()>, gpui::AnyWindowHandle) { unimplemented!() } +# struct CounterView { value: usize } +# fn current_handles() -> (gpui::Entity, gpui::AnyWindowHandle) { unimplemented!() } #[when("the view is updated through a reconstructed visual context")] fn view_is_updated_through_reconstructed_visual_context( #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext, @@ -1869,17 +1870,18 @@ scenarios!( scenarios, prefer async steps, async fixtures, or the async test body. ```rust,no_run +use rstest_bdd::StepResult; use rstest_bdd_macros::when; #[when("the stream ends")] -fn end_stream() { +fn end_stream() -> StepResult<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() - .build() - .expect("build step runtime"); + .build()?; runtime.block_on(async { // async work here }); + Ok(()) } ``` diff --git a/docs/users-guide.md b/docs/users-guide.md index 9199008..6987232 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -28,6 +28,11 @@ makeutil parse --stdin-filename Makefile - < Makefile arguments are command-line-only: environment variables and configuration files cannot supply them. +Path and standard-input sources may contain at most 16 MiB (16,777,216 bytes). +The limit is inclusive. A larger source fails before parsing with exit code 2, +writes a `makeutil: source-too-large: DETAIL` diagnostic to standard error, and +emits no JSON. + ## Interpret results The normative output contract is diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index ca79680..2d31d94 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -143,8 +143,10 @@ fn render_clap_error(error: &clap::Error, streams: &mut ProcessCapabilities<'_>) if error.use_stderr() { return fatal(streams.stderr, "cli", rendered.trim_end()); } - let _write_result = streams.stdout.write_all(rendered.as_bytes()); - ProcessOutcome { exit_code: 0 } + match streams.stdout.write_all(rendered.as_bytes()) { + Ok(()) => ProcessOutcome { exit_code: 0 }, + Err(write_error) => fatal(streams.stderr, "stdout-write", &write_error.to_string()), + } } fn run_parse( @@ -224,7 +226,7 @@ fn read_input( })?; return read_stdin(streams.stdin) .map(|bytes| (bytes, logical_path)) - .map_err(|error| fatal(streams.stderr, "source-read", &error.to_string())); + .map_err(|error| fatal(streams.stderr, error.operation(), &error.to_string())); } if arguments.stdin_filename.is_some() { return Err(fatal( diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index 127a9f5..af394b9 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -327,61 +327,5 @@ fn span( } #[cfg(test)] -mod tests { - //! Adapter invariant tests for unsupported upstream syntax. - - use makefile_lossless::{Makefile, Parse}; - use pretty_assertions::assert_eq; - use rstest::rstest; - - use super::{assignment_operator, condition_kind, ensure_round_trip}; - use crate::{domain::AssignmentOperator, ports::ParserPortError}; - - #[rstest] - fn unknown_condition_kind_is_rejected() { - assert_eq!( - condition_kind("ifunknown"), - Err(ParserPortError::UnsupportedConditionKind { - kind: "ifunknown".to_owned(), - }) - ); - } - - #[rstest] - fn round_trip_mismatch_is_rejected() { - let parsed = Parse::::parse_makefile("all:\n"); - - assert_eq!( - ensure_round_trip(&parsed.tree(), "different:\n"), - Err(ParserPortError::RoundTripMismatch) - ); - } - - #[rstest] - fn define_without_operator_uses_empty_schema_variant() { - assert_eq!( - assignment_operator(None, true), - Ok(AssignmentOperator::Define) - ); - } - - #[rstest] - fn ordinary_variable_requires_an_operator() { - assert_eq!( - assignment_operator(None, false), - Err(ParserPortError::MissingField { - field: "variable-assignment-operator", - }) - ); - } - - #[rstest] - fn unsupported_assignment_operator_is_rejected() { - assert_eq!( - assignment_operator(Some("unknown"), false), - Err(ParserPortError::UnsupportedAssignmentOperator { - operator: "unknown".to_owned(), - }) - ); - } -} +#[path = "makefile_tests.rs"] +mod tests; diff --git a/src/adapters/makefile_tests.rs b/src/adapters/makefile_tests.rs new file mode 100644 index 0000000..d46d8e7 --- /dev/null +++ b/src/adapters/makefile_tests.rs @@ -0,0 +1,84 @@ +//! Adapter invariant tests for unsupported upstream syntax. + +use makefile_lossless::{Makefile, Parse}; +use pretty_assertions::assert_eq; +use rstest::rstest; + +use super::{MakefileLosslessParser, assignment_operator, condition_kind, ensure_round_trip}; +use crate::{ + domain::AssignmentOperator, + ports::{MakefileParser as _, ParserPortError, SyntaxObservation}, +}; + +#[rstest] +fn unknown_condition_kind_is_rejected() { + assert_eq!( + condition_kind("ifunknown"), + Err(ParserPortError::UnsupportedConditionKind { + kind: "ifunknown".to_owned(), + }) + ); +} + +#[rstest] +fn round_trip_mismatch_is_rejected() { + let parsed = Parse::::parse_makefile("all:\n"); + + assert_eq!( + ensure_round_trip(&parsed.tree(), "different:\n"), + Err(ParserPortError::RoundTripMismatch) + ); +} + +#[rstest] +fn define_without_operator_uses_empty_schema_variant() { + assert_eq!( + assignment_operator(None, true), + Ok(AssignmentOperator::Define) + ); +} + +#[rstest] +fn ordinary_variable_requires_an_operator() { + assert_eq!( + assignment_operator(None, false), + Err(ParserPortError::MissingField { + field: "variable-assignment-operator", + }) + ); +} + +#[rstest] +fn unsupported_assignment_operator_is_rejected() { + assert_eq!( + assignment_operator(Some("unknown"), false), + Err(ParserPortError::UnsupportedAssignmentOperator { + operator: "unknown".to_owned(), + }) + ); +} + +#[rstest] +fn multiline_define_preserves_exact_body() { + let source = include_str!("../../tests/fixtures/makefiles/multiline-define.mk"); + let outcome = MakefileLosslessParser + .parse(source) + .expect("multiline define fixture should parse"); + let variable = outcome.observations.iter().find_map(|observation| { + if let SyntaxObservation::Variable { + operator, + raw_value, + .. + } = observation + { + Some((operator.to_owned(), raw_value.as_str())) + } else { + None + } + }); + + assert_eq!( + variable, + Some((AssignmentOperator::Define, "echo one \necho two\t \n")) + ); +} diff --git a/src/adapters/source.rs b/src/adapters/source.rs index d806ed3..f444e16 100644 --- a/src/adapters/source.rs +++ b/src/adapters/source.rs @@ -5,6 +5,11 @@ use std::io::Read as _; use camino::Utf8Path; use thiserror::Error; +/// Maximum accepted source size for both path and standard-input reads. +pub const MAX_SOURCE_BYTES: usize = 16 * 1024 * 1024; + +const BOUNDED_READ_BYTES: u64 = 16 * 1024 * 1024 + 1; + /// Capability for opening one logical source path as a byte stream. pub trait SourceReader { /// Open `path` for reading without resolving ambient authority. @@ -34,6 +39,14 @@ pub enum SourceReadError { /// Input/output error. source: std::io::Error, }, + /// The source exceeded [`MAX_SOURCE_BYTES`]. + #[error("source {path} exceeds the {limit}-byte limit")] + TooLarge { + /// Logical input path. + path: String, + /// Maximum accepted byte length. + limit: usize, + }, } impl SourceReadError { @@ -43,6 +56,7 @@ impl SourceReadError { match self { Self::Open { .. } => "source-open", Self::Read { .. } => "source-read", + Self::TooLarge { .. } => "source-too-large", } } } @@ -51,7 +65,8 @@ impl SourceReadError { /// /// # Errors /// -/// Returns [`SourceReadError`] when the source cannot be opened or read. +/// Returns [`SourceReadError`] when the source cannot be opened, read, or +/// exceeds [`MAX_SOURCE_BYTES`]. pub fn read_path( reader: &(impl SourceReader + ?Sized), path: &Utf8Path, @@ -61,27 +76,37 @@ pub fn read_path( path: display_path.clone(), source, })?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|source| SourceReadError::Read { - path: display_path, - source, - })?; - Ok(bytes) + read_bounded(&mut file, display_path) } /// Read all bytes from an injected standard-input reader. /// /// # Errors /// -/// Returns [`SourceReadError::Read`] when the stream fails. +/// Returns [`SourceReadError`] when the stream fails or exceeds +/// [`MAX_SOURCE_BYTES`]. pub fn read_stdin(reader: &mut (impl std::io::Read + ?Sized)) -> Result, SourceReadError> { + read_bounded(reader, "standard input".to_owned()) +} + +fn read_bounded( + reader: &mut (impl std::io::Read + ?Sized), + display_path: String, +) -> Result, SourceReadError> { let mut bytes = Vec::new(); reader + .take(BOUNDED_READ_BYTES) .read_to_end(&mut bytes) .map_err(|source| SourceReadError::Read { - path: "standard input".to_owned(), + path: display_path.clone(), source, })?; - Ok(bytes) + if bytes.len() > MAX_SOURCE_BYTES { + Err(SourceReadError::TooLarge { + path: display_path, + limit: MAX_SOURCE_BYTES, + }) + } else { + Ok(bytes) + } } diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs index c78b413..05bf20e 100644 --- a/tests/cli_e2e.rs +++ b/tests/cli_e2e.rs @@ -53,6 +53,10 @@ fn recovered_path_exits_one_with_json(mut makeutil_command: Command) { #[rstest] #[case(&["parse", "-"][..], "--stdin-filename")] #[case(&["parse"][..], "Usage:")] +#[case( + &["parse", "--stdin-filename", "Makefile", "ordinary.mk"][..], + "only valid when PATH is -" +)] fn invalid_invocation_exits_two( mut makeutil_command: Command, #[case] arguments: &[&str], diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index f5cbf3e..143718c 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -114,9 +114,9 @@ fn recovered_parse_retains_facts_and_diagnostics() { #[case("A ?= six\n", AssignmentOperator::Conditional, "six")] #[case("A != printf seven\n", AssignmentOperator::Shell, "printf seven")] #[case::define_block( - "define SCRIPT\necho one\necho two\nendef\n", + include_str!("fixtures/makefiles/multiline-define.mk"), AssignmentOperator::Define, - "echo one\necho two\n" + "echo one \necho two\t \n" )] fn assignment_operators_remain_source_faithful( #[case] source: &str, diff --git a/tests/fixtures/makefiles/multiline-define.mk b/tests/fixtures/makefiles/multiline-define.mk new file mode 100644 index 0000000..7306d44 --- /dev/null +++ b/tests/fixtures/makefiles/multiline-define.mk @@ -0,0 +1,4 @@ +define SCRIPT +echo one +echo two +endef diff --git a/tests/output_failures.rs b/tests/output_failures.rs index a1af4d2..ce4caf6 100644 --- a/tests/output_failures.rs +++ b/tests/output_failures.rs @@ -6,7 +6,10 @@ mod failing_reader; use common::MockSourceReader; use failing_reader::failing_reader; -use makeutil::adapters::cli::{ProcessCapabilities, run_from, run_from_with_reader}; +use makeutil::adapters::{ + cli::{ProcessCapabilities, run_from, run_from_with_reader}, + source::MAX_SOURCE_BYTES, +}; use rstest::rstest; struct FailingWriter; @@ -37,6 +40,23 @@ fn broken_stdout_exits_two_with_stable_operation() { assert!(String::from_utf8_lossy(&stderr).contains("makeutil: stdout-write:")); } +#[rstest] +#[case::help("--help")] +#[case::version("--version")] +fn broken_clap_display_exits_two_with_stable_operation(#[case] display_argument: &str) { + let mut stdin = std::io::empty(); + let mut stdout = FailingWriter; + let mut stderr = Vec::new(); + let outcome = run_from( + ["makeutil", display_argument], + &mut stdin, + &mut stdout, + &mut stderr, + ); + assert_eq!(outcome.exit_code, 2); + assert!(String::from_utf8_lossy(&stderr).contains("makeutil: stdout-write:")); +} + #[rstest] fn broken_path_reader_exits_two_with_stable_operation() { let mut source_reader = MockSourceReader::new(); @@ -52,3 +72,41 @@ fn broken_path_reader_exits_two_with_stable_operation() { assert_eq!(outcome.exit_code, 2); assert!(String::from_utf8_lossy(&stderr).contains("makeutil: source-read:")); } + +#[rstest] +fn oversized_stdin_exits_two_with_stable_operation() { + let mut stdin = std::io::repeat(b'x'); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let outcome = run_from( + ["makeutil", "parse", "--stdin-filename", "Makefile", "-"], + &mut stdin, + &mut stdout, + &mut stderr, + ); + assert_eq!(outcome.exit_code, 2); + assert!(stdout.is_empty()); + let diagnostic = String::from_utf8_lossy(&stderr); + assert!( + diagnostic.contains("makeutil: source-too-large:"), + "unexpected diagnostic: {diagnostic}" + ); +} + +#[rstest] +fn oversized_path_exits_two_with_stable_operation() { + let mut source_reader = MockSourceReader::new(); + source_reader + .expect_open() + .returning(|_| Ok(Box::new(std::io::repeat(b'x')))); + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let capabilities = + ProcessCapabilities::new(&mut stdin, &mut stdout, &mut stderr, &source_reader); + let outcome = run_from_with_reader(["makeutil", "parse", "Makefile"], capabilities); + assert_eq!(outcome.exit_code, 2); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).contains("makeutil: source-too-large:")); + assert!(String::from_utf8_lossy(&stderr).contains(&MAX_SOURCE_BYTES.to_string())); +} diff --git a/tests/source_adapter.rs b/tests/source_adapter.rs index 8038a94..358f733 100644 --- a/tests/source_adapter.rs +++ b/tests/source_adapter.rs @@ -4,13 +4,13 @@ mod common; #[path = "common/failing_reader.rs"] mod failing_reader; -use std::io::Cursor; +use std::io::{Cursor, Read as _}; use camino::Utf8Path; use common::MockSourceReader; use failing_reader::failing_reader; use googletest::prelude::*; -use makeutil::adapters::source::read_path; +use makeutil::adapters::source::{MAX_SOURCE_BYTES, SourceReadError, read_path, read_stdin}; use rstest::rstest; #[rstest] @@ -43,3 +43,49 @@ fn read_failures_keep_the_stable_operation() -> googletest::Result<()> { let error = read_path(&reader, Utf8Path::new("Makefile")).expect_err("reading should fail"); verify_that!(error.operation(), eq("source-read")) } + +#[rstest] +fn path_source_over_limit_is_rejected() -> googletest::Result<()> { + let mut reader = MockSourceReader::new(); + reader + .expect_open() + .returning(|_| Ok(Box::new(std::io::repeat(b'x')))); + let error = read_path(&reader, Utf8Path::new("Makefile")) + .expect_err("oversized path source should fail"); + verify_that!(error.operation(), eq("source-too-large"))?; + verify_that!( + matches!( + error, + SourceReadError::TooLarge { + limit: MAX_SOURCE_BYTES, + .. + } + ), + eq(true) + ) +} + +#[rstest] +fn standard_input_over_limit_is_rejected() -> googletest::Result<()> { + let mut stdin = std::io::repeat(b'x'); + let error = read_stdin(&mut stdin).expect_err("oversized stdin should fail"); + verify_that!(error.operation(), eq("source-too-large"))?; + verify_that!( + matches!( + error, + SourceReadError::TooLarge { + limit: MAX_SOURCE_BYTES, + .. + } + ), + eq(true) + ) +} + +#[rstest] +fn standard_input_at_limit_is_accepted() -> googletest::Result<()> { + let limit = u64::try_from(MAX_SOURCE_BYTES).expect("source limit should fit u64"); + let mut stdin = std::io::repeat(b'x').take(limit); + let bytes = read_stdin(&mut stdin)?; + verify_that!(bytes.len(), eq(MAX_SOURCE_BYTES)) +} From 15b6630e78cd938c86895d086879053c4332e7fe Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 19 Jul 2026 08:03:53 +0100 Subject: [PATCH 18/29] Clarify OrthoConfig guide provenance --- docs/ortho-config-users-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 3c8ee3f..1fbc73a 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -11,8 +11,8 @@ environment variables and configuration files into a single, strongly typed configuration struct. It is inspired by tools such as `esbuild` and is designed to minimize boiler‑plate. The library uses `serde` for deserialization and `clap` for argument parsing, while `figment` provides layered configuration -management. This guide covers the functionality currently implemented in the -repository. +management. This guide covers the functionality implemented in the upstream +OrthoConfig repository. ## Core concepts and motivation From 9be2a5601ddc7de1afb38ba9afb6952703201bb2 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 19 Jul 2026 13:33:25 +0200 Subject: [PATCH 19/29] Add estate corpus fixture for bare error directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operation Parabellum's estate audit found that leynos/pg-embed-setup-unpriv's Makefile guards an empty VERSION with a bare `$(error ...)` directive inside an `ifeq` block — legal GNU Make that `makefile-lossless` 0.3.40 (patched) cannot yet represent, forcing a recovered parse. Add a reduced fixture and a corpus test pinning the honest behaviour: the parse degrades to `recovered` with a positioned diagnostic, and the facts before and after the unsupported directive survive. The fixture also joins the schema-validation cases. If a future parser upgrade learns the construct, the corpus test fails on purpose so the pin and expectations are revisited together. --- tests/corpus.rs | 51 +++++++++++++++++++ .../makefiles/conditional-error-directive.mk | 12 +++++ tests/report_schema.rs | 4 ++ 3 files changed, 67 insertions(+) create mode 100644 tests/corpus.rs create mode 100644 tests/fixtures/makefiles/conditional-error-directive.mk diff --git a/tests/corpus.rs b/tests/corpus.rs new file mode 100644 index 0000000..3a97fe6 --- /dev/null +++ b/tests/corpus.rs @@ -0,0 +1,51 @@ +//! Real-estate corpus regressions. +//! +//! Each fixture here reduces a construct observed in a leynos repository +//! during the Operation Parabellum estate audit. The tests pin the parser's +//! honest behaviour for constructs it cannot yet represent: the parse must +//! degrade to `recovered` with a positioned diagnostic, never report a +//! false `complete`. If an upstream `makefile-lossless` release learns one +//! of these constructs, the corresponding test fails on purpose so the pin +//! and the expectations are revisited together. + +use makeutil::{adapters::MakefileLosslessParser, domain::ParseStatus, parse_source}; +use pretty_assertions::assert_eq; +use rstest::rstest; + +/// A bare `$(error ...)` directive inside a conditional (from +/// leynos/pg-embed-setup-unpriv) must parse as recovered, with the +/// surrounding facts retained. +#[rstest] +fn bare_error_directive_recovers_with_facts_retained() -> Result<(), Box> { + let report = parse_source( + include_bytes!("fixtures/makefiles/conditional-error-directive.mk"), + "conditional-error-directive.mk", + &MakefileLosslessParser, + )?; + + assert_eq!(report.parse.status, ParseStatus::Recovered); + assert!( + !report.parse.diagnostics.is_empty(), + "a recovered parse must carry at least one diagnostic", + ); + + let variable_names: Vec<&str> = report + .variables + .iter() + .map(|variable| variable.name.as_str()) + .collect(); + assert!( + variable_names.contains(&"VERSION"), + "facts before the unsupported directive must survive: {variable_names:?}", + ); + let rule_targets: Vec<&str> = report + .rules + .iter() + .flat_map(|rule| rule.targets.iter().map(String::as_str)) + .collect(); + assert!( + rule_targets.contains(&"build"), + "facts after the unsupported directive must survive: {rule_targets:?}", + ); + Ok(()) +} diff --git a/tests/fixtures/makefiles/conditional-error-directive.mk b/tests/fixtures/makefiles/conditional-error-directive.mk new file mode 100644 index 0000000..5536018 --- /dev/null +++ b/tests/fixtures/makefiles/conditional-error-directive.mk @@ -0,0 +1,12 @@ +# Reduced from leynos/pg-embed-setup-unpriv: a read-time guard written as a +# bare $(error ...) function directive inside a conditional block. GNU Make +# accepts this, but makefile-lossless 0.3.40 (patched) cannot yet represent +# a bare function directive, so the parse must degrade to `recovered` rather +# than report a false `complete`. +VERSION ?= +ifeq ($(strip $(VERSION)),) +$(error VERSION is empty; set version in Cargo.toml or pass VERSION explicitly) +endif + +build: + cargo build diff --git a/tests/report_schema.rs b/tests/report_schema.rs index 2419a11..7725540 100644 --- a/tests/report_schema.rs +++ b/tests/report_schema.rs @@ -42,6 +42,10 @@ fn all_facts_report() -> Result { #[rstest] #[case(include_bytes!("fixtures/makefiles/all-facts.mk"), "complete.mk")] #[case(include_bytes!("fixtures/makefiles/recovered.mk"), "recovered.mk")] +#[case( + include_bytes!("fixtures/makefiles/conditional-error-directive.mk"), + "conditional-error-directive.mk" +)] fn reports_validate_against_schema( #[case] source: &[u8], #[case] path: &str, From c5c0b7e8e80447f4f447e9bc91aaddd123387aa9 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 22 Jul 2026 01:24:57 +0200 Subject: [PATCH 20/29] Harden parser review fixtures Use direct test-boundary assertions and transfer BDD arguments without an unnecessary clone. Remove internal provenance from the corpus evidence and the accompanying implementation plan. --- .../adr-0001-single-file-gnu-make-parse.md | 8 ++++---- tests/corpus.rs | 14 +++++++------- .../makefiles/conditional-error-directive.mk | 4 ++-- tests/parse_bdd.rs | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 9856ca6..3d06bba 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -994,10 +994,10 @@ length before timing. The nested input contained 256 deterministic `ifdef`/ the 10 MiB median was 0.12 seconds, and all resident-set measurements were below 256 MiB. -From `/data/leynos/Projects/concordat`, a Python 3.13 subprocess invoked the -release binary against Concordat's Makefile, decoded JSON with the standard -library, asserted schema version 1 and complete status, and found `build`, -`lint`, and `test`. The successful summary was: +An external Python subprocess invoked the release binary against a +representative Makefile, decoded JSON with the standard library, asserted +schema version 1 and complete status, and found `build`, `lint`, and `test`. +The successful summary was: ```plaintext {"schema_version":1,"status":"complete","required_targets":["build","lint","test"],"language_binding":false} diff --git a/tests/corpus.rs b/tests/corpus.rs index 3a97fe6..ea35eaf 100644 --- a/tests/corpus.rs +++ b/tests/corpus.rs @@ -1,7 +1,7 @@ //! Real-estate corpus regressions. //! -//! Each fixture here reduces a construct observed in a leynos repository -//! during the Operation Parabellum estate audit. The tests pin the parser's +//! Each fixture here reduces a construct observed in an external repository +//! during a prior compatibility audit. The tests pin the parser's //! honest behaviour for constructs it cannot yet represent: the parse must //! degrade to `recovered` with a positioned diagnostic, never report a //! false `complete`. If an upstream `makefile-lossless` release learns one @@ -12,16 +12,17 @@ use makeutil::{adapters::MakefileLosslessParser, domain::ParseStatus, parse_sour use pretty_assertions::assert_eq; use rstest::rstest; -/// A bare `$(error ...)` directive inside a conditional (from -/// leynos/pg-embed-setup-unpriv) must parse as recovered, with the +/// A bare `$(error ...)` directive inside a conditional must parse as +/// recovered, with the /// surrounding facts retained. #[rstest] -fn bare_error_directive_recovers_with_facts_retained() -> Result<(), Box> { +fn bare_error_directive_recovers_with_facts_retained() { let report = parse_source( include_bytes!("fixtures/makefiles/conditional-error-directive.mk"), "conditional-error-directive.mk", &MakefileLosslessParser, - )?; + ) + .expect("the conditional-error-directive corpus fixture must parse into a report"); assert_eq!(report.parse.status, ParseStatus::Recovered); assert!( @@ -47,5 +48,4 @@ fn bare_error_directive_recovers_with_facts_retained() -> Result<(), Box Date: Thu, 23 Jul 2026 21:59:14 +0200 Subject: [PATCH 21/29] Preserve parser diagnostics and anonymize evidence Retain both upstream diagnostic channels, propagate logical stdin paths, and keep error-location work linear in source size. Strengthen the parser, schema, and failure-path regression contracts. Replace operational provenance with reproducible technical evidence and move the spelling baseline into a tracked project-owned source. --- .gitattributes | 2 +- README.md | 3 - data/typos-oxendict-base.toml | 209 +++++++++++++ docs/adrs/0001-single-file-gnu-make-parse.md | 22 +- docs/design.md | 35 +-- docs/developers-guide.md | 9 +- .../adr-0001-single-file-gnu-make-parse.md | 281 +++++++++--------- docs/ortho-config-users-guide.md | 19 +- docs/repository-layout.md | 4 + docs/rstest-bdd-users-guide.md | 69 +++-- docs/terms-of-reference.md | 36 +-- scripts/generate_typos_config.py | 20 +- scripts/typos_rollout.py | 14 +- src/adapters/cli.rs | 2 +- src/adapters/makefile.rs | 53 ++-- src/adapters/makefile_tests.rs | 25 +- src/adapters/source.rs | 9 +- tests/domain_contract.rs | 36 ++- tests/output_failures.rs | 25 ++ ...ema__recovered_output_has_stable_json.snap | 12 + tests/source_adapter.rs | 23 +- 21 files changed, 604 insertions(+), 304 deletions(-) create mode 100644 data/typos-oxendict-base.toml diff --git a/.gitattributes b/.gitattributes index 16f862f..44e2cb0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ # Preserve intentional trailing whitespace in the exact-byte parser fixture. -tests/fixtures/makefiles/multiline-define.mk -diff +tests/fixtures/makefiles/multiline-define.mk text eol=lf -diff diff --git a/README.md b/README.md index 6a303de..711eec1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # makeutil -[![Ask DeepWiki](https://deepwiki.com/badge.svg)]( -https://deepwiki.com/leynos/makeutil) - *Parse one GNU Makefile into deterministic, source-faithful JSON.* `makeutil` reports rules, recipes, variables, includes, conditionals, source diff --git a/data/typos-oxendict-base.toml b/data/typos-oxendict-base.toml new file mode 100644 index 0000000..b2ae769 --- /dev/null +++ b/data/typos-oxendict-base.toml @@ -0,0 +1,209 @@ +# Project-owned en-GB-oxendict dictionary for makeutil. +# +# Keep only generally valid Oxford forms and project-wide terms here. Product +# names, quoted fixtures, and repository-specific identifiers belong in that +# repository's typos.local.toml overlay. + +schema = 1 + +[oxford] +stems = [ + "absolut", + "actual", + "alphabet", + "amort", + "anonym", + "apolog", + "atom", + "author", + "canonical", + "capital", + "categor", + "central", + "character", + "civil", + "colon", + "colour", + "common", + "conceptual", + "concret", + "container", + "contextual", + "critic", + "crystall", + "custom", + "daemon", + "decentral", + "denormal", + "derandom", + "deserial", + "desynchron", + "destabil", + "docker", + "dramat", + "emphas", + "energ", + "equal", + "external", + "factor", + "familiar", + "final", + "formal", + "fossil", + "general", + "global", + "harmon", + "homogen", + "human", + "hypothes", + "ideal", + "initial", + "industrial", + "institutional", + "internal", + "international", + "italic", + "item", + "jeopard", + "kernel", + "legitim", + "lemmat", + "linear", + "local", + "material", + "maxim", + "mechan", + "memo", + "memor", + "miniatur", + "minim", + "mobil", + "modern", + "modular", + "monet", + "monomorph", + "monopol", + "moral", + "neutral", + "normal", + "notar", + "operational", + "optim", + "ordinal", + "oxid", + "organ", + "palett", + "parallel", + "parameter", + "parametr", + "parenthes", + "penal", + "personal", + "plural", + "polymer", + "popular", + "priorit", + "privat", + "product", + "public", + "quant", + "random", + "raster", + "rational", + "real", + "recogn", + "regular", + "reinitial", + "reorgan", + "reserial", + "resynchron", + "retoken", + "romantic", + "sanit", + "scrutin", + "serial", + "sexual", + "singular", + "social", + "special", + "stabil", + "standard", + "styl", + "subsid", + "summar", + "symbol", + "synchron", + "synthes", + "system", + "systemat", + "templat", + "terminal", + "theor", + "token", + "trivial", + "unauthor", + "uncategor", + "underutil", + "uninitial", + "unlocal", + "unoptim", + "unparameter", + "unrecogn", + "unsynchron", + "util", + "vector", + "virtual", + "visual", + "weapon", + "zero", +] + +[words] +accepted = [ + "ASO", + "dialog", + "dialogs", + "handwritten", + "organizational", + "oxendict", + "yse", +] + +[words.corrections] +organisational = "organizational" + +[phrases.corrections] +"hand-written" = "handwritten" + +[patterns] +ignore = [ + '`[^`\n]+`', + '(?s)```.*?```', + '\brust-analyzer\b', +] + +[files] +exclude = [ + ".git", + ".hypothesis", + ".pytest_cache", + ".tox", + ".terraform", + ".typos-oxendict-base.json", + ".typos-oxendict-base.toml", + ".uv-cache", + ".uv-tools", + ".venv", + "Cargo.lock", + "bun.lock", + "data/typos-oxendict-base.toml", + "dist", + "node_modules", + "package-lock.json", + "pnpm-lock.yaml", + "target", + "typos.local.toml", + "typos.toml", + "uv.lock", + "yarn.lock", +] diff --git a/docs/adrs/0001-single-file-gnu-make-parse.md b/docs/adrs/0001-single-file-gnu-make-parse.md index cc0d757..07861b1 100644 --- a/docs/adrs/0001-single-file-gnu-make-parse.md +++ b/docs/adrs/0001-single-file-gnu-make-parse.md @@ -6,11 +6,11 @@ Accepted on 2026-07-13 ## Context -Concordat's first Rust policy slice needs to inspect root Makefiles for -required targets and known lint-gate bypasses. OPA and Rego consume structured -data; they should not parse Make syntax. Invoking GNU Make to inspect an -untrusted file would also cross the static-analysis boundary because Make -expands functions and may invoke external commands while reading source. +A downstream policy consumer needs to inspect root Makefiles for required +targets and known lint-gate bypasses. OPA and Rego consume structured data; +they should not parse Make syntax. Invoking GNU Make to inspect an untrusted +file would also cross the static-analysis boundary because Make expands +functions and may invoke external commands while reading source. The Rust crate [`makefile-lossless`](https://github.com/jelmer/makefile-lossless) supplies a @@ -72,13 +72,13 @@ compliance. | Output | Compact JSON schema v1 | JSON Lines or other reporters | | Mutation | None | Source-preserving rewrite command | | Integration | Subprocess CLI | Optional Python or other bindings | -| Policy | None | Remains owned by Concordat and Rego | +| Policy | None | Remains owned by consumers and Rego | ## Consequences ### Positive -- Concordat gets the exact evidence needed for its first policy without owning a +- The downstream policy consumer gets the evidence it needs without owning a Make parser. - Rego receives ordinary structured data with source locations. - Untrusted Makefile content remains inert. @@ -109,15 +109,15 @@ Rejected. GNU Make may expand functions and execute shell commands while reading source. It also reports an evaluated database rather than the source-faithful facts needed for precise diagnostics and later editing. -### Parse Makefiles in Python inside Concordat +### Parse Makefiles in Python inside the consumer Rejected. This would duplicate a difficult grammar, lose the proven lossless -CST, and bind parser maintenance to Concordat. +CST, and bind parser maintenance to the consumer. ### Bind the Rust crate directly into Python now Rejected for the first slice. PyO3 would add native wheel distribution and -couple Concordat directly to Rust packaging before subprocess overhead has +couple the consumer directly to Rust packaging before subprocess overhead has shown itself to matter. ### Expose a C ABI for Go or OpenTofu integration @@ -139,6 +139,6 @@ and decision. QG-001. 4. Recovered parses never exit 0. 5. Hostile Make functions and recipes cause no external side effect. -6. Concordat consumes the output through JSON without a language binding. +6. An external consumer can use the JSON output without a language binding. 7. Include traversal, mutation, discovery, and policy remain absent from the implementation. diff --git a/docs/design.md b/docs/design.md index 1ce2392..c791a02 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,15 +1,15 @@ # makeutil technical design - **Status:** Draft v0.1 -- **Audience:** Implementers, maintainers, Concordat integrators, and reviewers +- **Audience:** Implementers, maintainers, downstream integrators, and reviewers - **Companion documents:** [Terms of reference](terms-of-reference.md) and [ADR-0001](adrs/0001-single-file-gnu-make-parse.md) ## 1. Problem statement -Concordat needs structured evidence from Makefiles before OPA can enforce Rust -lint policy. Rego should not parse Make syntax, and Concordat should not invoke -GNU Make to inspect an untrusted file. +A downstream policy consumer needs structured evidence from Makefiles before +OPA can enforce Rust lint policy. Rego should not parse Make syntax, and the +consumer should not invoke GNU Make to inspect an untrusted file. The selected `makefile-lossless` crate already provides a lossless Rowan concrete syntax tree, recovered parse results, source ranges, and focused APIs @@ -29,7 +29,7 @@ Make's complete evaluation semantics. - Flatten nested conditional content while preserving branch ancestry. - Surface parser recovery explicitly. - Keep the upstream CST and API out of consumer contracts. -- Support the first Concordat FP-003 and QG-001 policy slice. +- Support the first downstream FP-003 and QG-001 policy slice. ### 2.2. Non-goals @@ -53,7 +53,7 @@ and `VAR != command` remains inert source data. ### 3.2. Report evidence, not conclusions `makeutil` reports that a variable uses `?=` or that a recipe starts with `-`. -Concordat and Rego decide whether those facts violate policy. +The downstream consumer and Rego decide whether those facts violate policy. ### 3.3. Preserve uncertainty @@ -77,10 +77,10 @@ rewriting, and bindings remain later decisions. The implementation uses [`makefile-lossless`](https://github.com/jelmer/makefile-lossless), initially pinned to `=0.3.40`. A temporary `[patch.crates-io]` override selects commit -`8dd35801b75b332c2ac2f995ae398ef8238559fa` from the `leynos/makefile-lossless` -fork because release 0.3.40 does not lex the documented GNU Make `!=` -assignment operator. Remove the override when an upstream release containing -the fix is adopted; do not replace the immutable commit with a branch name. +`8dd35801b75b332c2ac2f995ae398ef8238559fa` from a project-maintained fork +because release 0.3.40 does not lex the documented GNU Make `!=` assignment +operator. Remove the override when an upstream release containing the fix is +adopted; do not replace the immutable commit with a branch name. The crate supplies: @@ -135,7 +135,7 @@ slice. | `1` | The parser recovered a tree with one or more diagnostics and JSON was emitted. | | `2` | Invocation, source reading, UTF-8 decoding, serialization, or internal failure prevented a parse result. | -The distinct recovered status lets Concordat fail closed while retaining useful +The distinct recovered status lets consumers fail closed while retaining useful source diagnostics. ## 6. Output model @@ -502,9 +502,10 @@ adapter and location mapping if real corpus defects justify it. The project ships one Rust binary named `makeutil`. -Concordat invokes the binary as a subprocess and validates `schema_version`. -The first slice does not expose PyO3 or Go bindings. This avoids a native wheel -matrix, cgo, and direct consumer coupling to upstream Rust types. +The downstream consumer invokes the binary as a subprocess and validates +`schema_version`. The first slice does not expose PyO3 or Go bindings. This +avoids a native wheel matrix, cgo, and direct consumer coupling to upstream +Rust types. CI and release packaging pin the executable version. The JSON schema, rather than the executable version string alone, controls compatibility. @@ -527,7 +528,7 @@ A later command must not silently change the behaviour or schema of `parse`. | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Consumers mistake syntax facts for effective Make semantics | Name fields literally, document the single-file scope, and preserve conditional and include evidence. | | Upstream `0.x` changes alter behaviour | Pin exactly and require fixture, golden, and round-trip review for upgrades. | -| Recovered parses lead to false passes | Emit `parse.status = recovered`, exit 1, and require Concordat to fail closed. | +| Recovered parses lead to false passes | Emit `parse.status = recovered`, exit 1, and require consumers to fail closed. | | Raw recipe matching becomes policy-specific parsing | Keep matching in Rego and limit the first rules to documented lexical patterns. | | Schema expands before evidence exists | Require a consumer use case and schema-version review for every new fact. | @@ -540,6 +541,6 @@ The design is implemented when: 3. Complete input round-trips byte-for-byte through the parser tree. 4. Recovered input emits diagnostics, partial facts, and exit code 1. 5. Rules, variables, recipes, includes, conditional ancestry, and source - locations cover the first Concordat fixtures. + locations cover the first downstream consumer fixtures. 6. No test source can cause command execution or network access. -7. Concordat can consume the output solely through JSON. +7. An external consumer can use the output solely through JSON. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0ad66df..cca4623 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -133,10 +133,11 @@ full generated workflow locally on Linux. ## Spelling policy Markdown uses en-GB-oxendict spelling enforced by the pinned `typos` release. -The tracked `typos.toml` is generated from the estate-wide shared dictionary -and the narrow repository overlay in `typos.local.toml`. Run `make spelling` to -refresh the ignored local shared-base cache when the published source is newer, -regenerate the tracked configuration, and check maintained prose. +The tracked `typos.toml` is generated from the project-owned dictionary in +`data/typos-oxendict-base.toml` and the narrow repository overlay in +`typos.local.toml`. Run `make spelling` to refresh the ignored local base cache +when the tracked source is newer, regenerate the configuration, and check +maintained prose. ### Security audit ignores diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 3d06bba..9f37420 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -66,10 +66,9 @@ effects. - Tests must not mutate the environment of the shared test process. Set environment variables only on child processes if a case requires them. - Run `make check-fmt`, `make typecheck`, `make lint`, and `make test` after - every major milestone, then have a scrutineer run - `coderabbit review --agent`. Resolve all deterministic gate failures before - requesting CodeRabbit. Resolve applicable CodeRabbit concerns as a separate - review action before proceeding. + every major milestone, then run an independent automated review. Resolve all + deterministic gate failures before requesting review. Resolve applicable + review concerns as a separate action before proceeding. - Commit each accepted milestone atomically after its gates and review pass. - Update user-facing behaviour in [the user's guide](../users-guide.md), internal interfaces and ownership in [the design](../design.md), repository @@ -100,8 +99,7 @@ stop and resolve the conflict before editing `Cargo.toml`. - Testability: stop if a fatal or recovered path can be tested only by panic, shared-process environment mutation, or lint suppression. - Quality: after three unsuccessful attempts to fix the same deterministic - gate or CodeRabbit blocker, record evidence and escalate rather than masking - it. + gate or review blocker, record evidence and escalate rather than masking it. - Performance: stop if a 10 MiB fixture takes over two seconds or a 256-level conditional fixture uses more than 256 MiB resident memory on the development machine in three consecutive release-mode measurements. These are guardrails, @@ -129,20 +127,20 @@ stop and resolve the conflict before editing `Cargo.toml`. approval as approval of one scoped exception, document it in the design and developer guide, and do not generalize it. - Risk: a schema represented only by Rust structs and snapshots is difficult - for Concordat to validate independently. Severity: medium. Likelihood: - medium. Mitigation: add a checked JSON Schema artefact for schema version 1 - and test representative complete and recovered documents against it. + for an external consumer to validate independently. Severity: medium. + Likelihood: medium. Mitigation: add a checked JSON Schema artefact for schema + version 1 and test representative complete and recovered documents against it. - Risk: tests might claim “no execution” while only testing ordinary recipes. Severity: high. Likelihood: medium. Mitigation: end-to-end hostile fixtures contain `$(shell ...)`, `!=`, recipe commands, dynamic includes, and literal includes that would create a sentinel if evaluated or opened. Assert the sentinel remains absent. -- Risk: the Concordat integration criterion is outside this repository. +- Risk: the external integration criterion is outside this repository. Severity: medium. Likelihood: high. Mitigation: provide a consumer-shaped - deserialization fixture and record the external Concordat trial as evidence - required before implementation is declared complete; do not fabricate - cross-repository proof. Outcome: the trial passed in the available Concordat - checkout. + deserialization fixture and record a reproducible subprocess trial as + evidence required before implementation is declared complete; do not + fabricate cross-repository proof. Outcome: a trial from an external consumer + environment passed. - Risk: strict lints and code-size limits may encourage premature abstraction. Severity: medium. Likelihood: medium. Mitigation: keep modules cohesive, sweep for equivalent helpers before every extraction, and add a trait only at @@ -152,19 +150,19 @@ stop and resolve the conflict before editing `Cargo.toml`. ## Progress - [x] (2026-07-13) Created the Leta workspace and mapped the scaffold, ADR, - design, documentation, test guidance, and build gates with a Wyvern team. + design, documentation, test guidance, and build gates through independent + review. - [x] (2026-07-13) Confirmed upstream `makefile-lossless` 0.3.40 exposes a lossless tree, recovered results, and ordinary and positioned diagnostics. - [x] (2026-07-13) Imported the OrthoConfig user's guide from `../../ortho-config/docs/users-guide.md` and indexed it. -- [x] (2026-07-13) Completed the Logisphere community review and revised the +- [x] (2026-07-13) Completed an independent architecture review and revised the design to freeze logical-path spelling, construct ranges, ordinal ownership, diagnostics, failure output, and observability before approval. - [x] (2026-07-13) Passed all planning milestone deterministic gates and - resolved every actionable concern from three CodeRabbit review rounds. -- [x] (2026-07-13) Obtained a clean CodeRabbit follow-up after the service rate - limit reset; the final pre-completion review examined 34 files and reported - zero findings. + resolved every actionable concern from three automated review rounds. +- [x] (2026-07-13) Obtained a clean automated follow-up; the final + pre-completion review examined 34 files and reported zero findings. - [x] (2026-07-13) Obtained explicit approval of this ExecPlan, including the exact parser pin exception and schema/path decisions. - [x] (2026-07-13) Milestone 1: proved upstream contracts and froze the @@ -178,8 +176,8 @@ stop and resolve the conflict before editing `Cargo.toml`. doctests, and pinned immutable commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` through `[patch.crates-io]`. - [x] (2026-07-13) Passed the complete deterministic makeutil gate set after - applying the patch; the scrutineer independently repeated every gate and - CodeRabbit completed with zero findings across 34 reviewed files. + applying the patch; independent validation repeated every gate and automated + review completed with zero findings across 34 reviewed files. - [x] (2026-07-13) Added a consumer-owned schema-v1 deserialization test with focused red/green and Clippy evidence. - [x] (2026-07-13) Completed the manual CLI acceptance exercise with path, @@ -187,22 +185,20 @@ stop and resolve the conflict before editing `Cargo.toml`. - [x] (2026-07-13) Measured exact-size 1, 5, and 10 MiB inputs and 256 nested conditionals in release mode; every run remained inside the elapsed-time and memory guardrails. -- [x] (2026-07-13) Ran the release binary from the Concordat Python 3.13 - environment, decoded schema v1 without a Rust binding, and found its required - `build`, `lint`, and `test` targets in a complete parse. +- [x] (2026-07-13) Ran the release binary from an external Python 3.13 consumer, + decoded schema v1 without a Rust binding, and found its required `build`, + `lint`, and `test` targets in a complete parse. - [x] (2026-07-13) Used `strace` to prove that existing literal and dynamic include paths were reported but never opened. - [x] (2026-07-13) Milestone 4: synchronized contracts, completed all acceptance - exercises, and passed every deterministic gate under independent scrutineer - validation. + exercises, and passed every deterministic gate under independent validation. - [x] (2026-07-14) Reviewed the terminal diff and applied valid fixes for trailing variable whitespace, recipe-modifier ordering, closed conditional kinds, focused CLI helpers, and documentation drift. The focused whitespace and modifier-order tests supplied red evidence. The complete deterministic - gate set then passed, and the scrutineer independently confirmed 45 of 45 - tests, two passing doctests with one intentionally ignored, and clean - formatting, Polonius type-checking, lint, documentation, diagram, and diff - checks. + gate set then passed, and independent validation confirmed 45 of 45 tests, + two passing doctests with one intentionally ignored, and clean formatting, + Polonius type-checking, lint, documentation, diagram, and diff checks. - [x] (2026-07-14) Injected ambient filesystem access at the CLI composition boundary. Red compilation proved the `SourceReader` and `run_from_with_reader` seams were absent; focused source-adapter, output- @@ -212,14 +208,14 @@ stop and resolve the conflict before editing `Cargo.toml`. spelling, Mermaid, and diff checks. - [x] (2026-07-14) Corrected documentation ownership and orientation drift and applied the valid fatal CLI helper and private `collect_items` constructor - fixes found during terminal review. The independent scrutineer confirmed 49 - of 49 tests, two passing doctests with one intentionally ignored, and clean + fixes found during terminal review. Independent validation confirmed 49 of 49 + tests, two passing doctests with one intentionally ignored, and clean `make check-fmt`, `make typecheck`, `make lint`, `make test`, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [x] (2026-07-14) Replaced duplicated integration-test source readers with one `mockall` definition under `tests/common`, keeping mock code out of the - production library. The scrutineer independently confirmed 49 of 49 tests, - two passing doctests with one intentionally ignored, and clean formatting, + production library. Independent validation confirmed 49 of 49 tests, two + passing doctests with one intentionally ignored, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. All targets compiled with warnings denied and no unused test helper. @@ -227,7 +223,7 @@ stop and resolve the conflict before editing `Cargo.toml`. index and repository layout and made the imported GPUI reset snippet's hidden state type match its field accesses. Added behavioural scenarios for invalid invocation, help, and version, and shared the all-facts report fixture. The - scrutineer independently confirmed 54 of 54 tests, two passing doctests with + independent validation confirmed 54 of 54 tests, two passing doctests with one intentionally ignored, and clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [x] (2026-07-16) Added direct regression coverage for the concrete parser's @@ -237,10 +233,10 @@ stop and resolve the conflict before editing `Cargo.toml`. type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. - [x] (2026-07-16) Closed the `AssignmentOperator` contract and enforced the - status/diagnostics schema invariant. The scrutineer independently confirmed - 72 of 72 tests, three passing doctests with one intentionally ignored, - unchanged snapshots, and clean formatting, Polonius type-checking, rustdoc, - Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. + status/diagnostics schema invariant. Independent validation confirmed 72 of + 72 tests, three passing doctests with one intentionally ignored, unchanged + snapshots, and clean formatting, Polonius type-checking, rustdoc, Clippy, + Whitaker, Markdown, spelling, Mermaid, and diff checks. - [x] (2026-07-18) Added one shared bounded-read implementation for path and standard-input sources, accepting at most 16 MiB and reporting larger sources through the stable `source-too-large` fatal operation. @@ -254,15 +250,15 @@ stop and resolve the conflict before editing `Cargo.toml`. - [x] (2026-07-18) Added a dedicated external multiline `define` fixture with embedded newlines and trailing whitespace, and asserted its exact domain representation through the concrete parser and domain contract suite. -- [x] (2026-07-18) The independent scrutineer confirmed 82 of 82 tests, three +- [x] (2026-07-18) Independent validation confirmed 82 of 82 tests, three passing doctests with one intentionally ignored, clean formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. It also confirmed that the lockfile and `typos.toml` are unchanged and that no-default feature resolution excludes `ortho_config/serde_json`. -- [ ] Obtain CodeRabbit certification of the exact terminal diff through the - pull request. The user approved deferral from the unavailable CLI review - during CodeRabbit's temporary outage. +- [ ] Obtain automated certification of the exact terminal diff through the + pull request. The user approved deferral while the review service was + temporarily unavailable. ## Surprises & discoveries @@ -278,20 +274,19 @@ stop and resolve the conflict before editing `Cargo.toml`. upstream tests assert recovered-tree and hostile-input round trips. Impact: an adapter can preserve partial evidence without exposing upstream types, but its contract must be proven before model implementation. -- Observation: the requested OrthoConfig guide originally did not exist in this - checkout. Evidence: the source guide lived at - `../../ortho-config/docs/users-guide.md`. Impact: it has been imported as - `docs/ortho-config-users-guide.md` and is now a local implementation +- Observation: the requested OrthoConfig guide was imported from its upstream + documentation. Impact: it is now available as + `docs/ortho-config-users-guide.md` and provides a local implementation reference. - Observation: `makefile-lossless` 0.3.40 documents `!=` as an assignment operator, but parses valid GNU Make `A != printf seven` as recovered rule fragments with diagnostics and exposes no `VariableDefinition`. Evidence: the focused `assignment_operators_remain_source_faithful::case_7` test and a live - CLI reproduction both produce zero variable facts; the scrutineer - independently reproduced the failure. Impact: this triggers the approved - upstream stop condition. The exact pin cannot satisfy the source-faithful - variable contract without an upstream fix, a separately approved narrow - fallback parser, or an explicit scope reduction. + CLI reproduction both produce zero variable facts; independent validation + reproduced the failure. Impact: this triggers the approved upstream stop + condition. The exact pin cannot satisfy the source-faithful variable contract + without an upstream fix, a separately approved narrow fallback parser, or an + explicit scope reduction. - Observation: the defect was confined to `Lexer::next_token`; the parser and AST accessors already recognized `!=`, but the operator-token start set omitted `!`. Evidence: fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` @@ -362,20 +357,20 @@ stop and resolve the conflict before editing `Cargo.toml`. ## Decision log -- Decision: defer exact terminal-diff CodeRabbit certification to the pull - request after the CLI first rate-limited and then required unavailable - browser authentication during a temporary service outage. Rationale: the - exact diff passed every independent deterministic gate, the immediately - preceding review was clean, and the user explicitly approved waiting for PR - review rather than blocking the commit. Date/Author: 2026-07-13 / User and - Codex. +- Decision: defer exact terminal-diff automated certification to the pull + request after the review service first rate-limited and then required + unavailable browser authentication during a temporary service outage. + Rationale: the exact diff passed every independent deterministic gate, the + immediately preceding review was clean, and approval to wait for pull-request + review was recorded rather than blocking the commit. Date/Author: 2026-07-13 + / Project maintainers. - Decision: generate large performance fixtures ephemerally and check their exact byte lengths with `stat` rather than commit 16 MiB of repetitive test data. Rationale: fixed `all:` and newline framing around repeated `a` bytes produces valid, deterministic rule fixtures while keeping the repository small; the measured command fails before timing if any size differs. Date/ - Author: 2026-07-13 / Codex. + Author: 2026-07-13 / Project maintainers. - Decision: patch crates.io resolution to immutable fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` while retaining the approved exact @@ -383,77 +378,77 @@ stop and resolve the conflict before editing `Cargo.toml`. the missing lexer start character and regression coverage without vendoring, changing makeutil policy, or exposing a mutable branch reference. Retire the patch when an adopted upstream release contains the fix. Date/Author: - 2026-07-13 / Codex. + 2026-07-13 / Project maintainers. - Decision: apply hexagonal architecture only at meaningful volatility and side-effect boundaries. Rationale: domain facts, locations, ordering, and parse outcome classification need pure tests; `makefile-lossless`, CLI parsing, filesystem access, and JSON output are adapters. Repositories, event buses, CQRS layers, and adapter-to- adapter traits would add ceremony without - protecting a real boundary. Date/Author: 2026-07-13 / Codex planning team. + protecting a real boundary. Date/Author: 2026-07-13 / Project maintainers. - Decision: define one domain-owned `MakefileParser` port and keep upstream CST observations on the adapter side. Rationale: the young parser crate is the principal volatile dependency. The port returns makeutil-owned facts and diagnostics so upstream APIs cannot leak into schema or application policy. - Date/Author: 2026-07-13 / Codex planning team. + Date/Author: 2026-07-13 / Project maintainers. - Decision: use property testing for `LocationIndex`, not Kani or Verus. Rationale: arbitrary UTF-8, newline layouts, and valid byte spans form a natural generative invariant. There is no bounded concurrent/state machine model for Kani and no introduced lemma or contractual business theorem that would make a substantive Verus proof possible. Adding either would be - performative rather than rigorous. Date/Author: 2026-07-13 / Codex planning - team. + performative rather than rigorous. Date/Author: 2026-07-13 / Project + maintainers. - Decision: provide JSON Schema Draft 2020-12 as a checked consumer artefact. Rationale: schema version 1 is the stable integration contract and must be independently machine-readable; Rust structs and snapshots alone are not an - adequate subprocess contract. Date/Author: 2026-07-13 / Codex planning team. + adequate subprocess contract. Date/Author: 2026-07-13 / Project maintainers. - Decision: use OrthoConfig 0.8.x for the `parse` subcommand while keeping input selection explicit and unlayered. Rationale: the imported guide is the requested CLI/configuration reference, but ADR-0001 allows no implicit path or discovery. OrthoConfig supplies typed CLI derivation and preserves help/version display exits; it must not add environment or file defaults for - `PATH` or `--stdin-filename`. Date/Author: 2026-07-13 / Codex planning team. + `PATH` or `--stdin-filename`. Date/Author: 2026-07-13 / Project maintainers. - Decision: preserve exact logical path spelling and use the complete construct-range rules in `docs/design.md` section 6.2. Rationale: callers need stable source slices and reproducible JSON. Deferring these choices until adapter implementation would make plan approval meaningless and accidentally turn upstream accessor choices into schema policy. Date/Author: - 2026-07-13 / Logisphere-reviewed Codex planning team. + 2026-07-13 / Independent planning review. - Decision: let the parser adapter return ordered makeutil-owned observations and source spans; keep round-trip bytes in adapter tests only. Rationale: location conversion, the ordinal ordering invariant, and status are makeutil-owned policy; `parse_source` and its `ReportAssembly` fact collector assign ordinals, while exact-byte hashing is application-service policy. Upstream CST renderings and error types must not leak through the - domain-owned port. Date/Author: 2026-07-13 / Logisphere-reviewed Codex - planning team. Ownership wording clarified on 2026-07-18 during terminal - documentation review. + domain-owned port. Date/Author: 2026-07-13 / Independent planning review. + Ownership wording clarified on 2026-07-18 during terminal documentation + review. - Decision: cap each path or standard-input source at an inclusive 16 MiB by composing both adapters through one private bounded-read helper. The helper is source-adapter implementation detail, not a port or general I/O utility. Rationale: one policy prevents input-dependent memory growth and keeps error classification identical across both input modes. Date/Author: 2026-07-18 / - Wyvern review team. + Independent review. - Decision: treat clap display writes as process output subject to `stdout-write`, while preserving clap's normal stream and exit-zero semantics when the complete display is written. Rationale: help and version output are externally observable process behaviour and cannot silently discard an I/O - failure. Date/Author: 2026-07-18 / Wyvern review team. + failure. Date/Author: 2026-07-18 / Independent review. - Decision: make makeutil's `serde_json` feature the sole switch for OrthoConfig's JSON integration and disable OrthoConfig default features. Rationale: feature ownership stays visible at the application manifest, and `--no-default-features` has predictable dependency behaviour. Date/Author: - 2026-07-18 / Wyvern review team. + 2026-07-18 / Independent review. - Decision: keep the multiline `define` regression as external Makefile input and exercise it through the concrete parser and application service. Rationale: the contract concerns exact source bytes across the adapter boundary, so an inline domain-only case cannot prove it. Date/Author: - 2026-07-18 / Wyvern review team. + 2026-07-18 / Independent review. - Decision: serialize to memory before stdout and permit partial stdout only when the operating system accepts a prefix before an output failure. Rationale: the process can prevent serialization failures from writing JSON, but cannot retract accepted bytes after a broken pipe or partial write. - Date/Author: 2026-07-13 / Logisphere-reviewed Codex planning team. + Date/Author: 2026-07-13 / Independent planning review. - Decision: keep review-driven helpers at their narrowest validated ownership boundary. `ConditionKind` is the closed domain/port representation consumed by observations and reports; the makefile adapter alone owns the private @@ -461,14 +456,14 @@ stop and resolve the conflict before editing `Cargo.toml`. helpers remain private to the CLI adapter. Rationale: these boundaries remove stringly typed drift and order-sensitive defects without creating reusable ports for implementation details. Permitted call sites and reuse policy are - recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Wyvern - review team. + recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Independent + review. - Decision: represent schema-v1 assignment operators with the closed, domain-owned `AssignmentOperator` enum shared by the parser port and report model. The empty representation is reserved for a `define` block without an assignment token. Rationale: the producer must reject upstream drift before serialization rather than emit JSON outside the checked schema. Date/Author: - 2026-07-16 / Wyvern review team. + 2026-07-16 / Independent review. - Decision: define `SourceReader` in the source adapter as a narrow capability interface, not a domain port. `read_path` owns byte collection and `SourceReadError` classification; `run_from` alone constructs the @@ -477,7 +472,7 @@ stop and resolve the conflict before editing `Cargo.toml`. this removes ambient authority from the reusable read function without transplanting filesystem concerns into the domain, introducing directory/ include semantics, or exceeding the repository's four-argument limit. Date/ - Author: 2026-07-14 / Codex. + Author: 2026-07-14 / Project maintainers. - Decision: share a `MockSourceReader` definition under `tests/common` rather than derive it on the production trait. Rationale: a `cfg_attr(test, automock)` type is not exported when the library is compiled @@ -486,7 +481,7 @@ stop and resolve the conflict before editing `Cargo.toml`. test-support feature, or generated mocks to the production surface. Keep the failing stream in a separate shared file included only by suites that use it, so warnings remain denied without suppressions. Date/Author: 2026-07-14 / - User and Codex. + Project maintainers. ## Outcomes & retrospective @@ -495,13 +490,13 @@ a capability-safe CLI and stable schema-v1 JSON. Unit, property, snapshot, BDD, and end-to-end tests cover complete, recovered, fatal, and inert-source paths. The forked parser fix restores source-faithful `!=` assignments without a makeutil-specific fallback. Manual CLI acceptance, release-mode guardrails, and -the Concordat subprocess and include-boundary trials all pass. Independent -scrutineer validation repeated every deterministic gate. The implementation of -ADR-0001's single-file GNU Make parse slice is complete. Ambient filesystem -authority is now composed once at the CLI boundary and injected through +the external consumer and include-boundary trials all pass. Independent +validation repeated every deterministic gate. The implementation of ADR-0001's +single-file GNU Make parse slice is complete. Ambient filesystem authority is +now composed once at the CLI boundary and injected through `ProcessCapabilities`; fake readers prove the source-open and source-read -contracts without filesystem access. Exact terminal-diff CodeRabbit -certification is deferred to the pull request because the CLI service became +contracts without filesystem access. Exact terminal-diff automated +certification is deferred to the pull request because the review service became unavailable, as explicitly approved by the user. ## Context and orientation @@ -540,9 +535,10 @@ Implementation must consult these practice guides at the relevant milestone: The implementing agent must load the `leta` skill for semantic navigation, the `rust-router` skill to select only a necessary Rust specialist, the `hexagonal-architecture` skill for boundary checks, and the `execplans` skill -to keep this document current. Use `firecrawl-mcp` only when an upstream API, -format, or prior-art gap remains after local documentation and exact dependency -source inspection. Use the `logisphere-experts` community for design reviews. +to keep this document current. Research authoritative upstream sources only +when an API, format, or prior-art gap remains after local documentation and +exact dependency source inspection. Use an independent design review for +substantive architecture decisions. The intended narrow dependency flow is: @@ -638,8 +634,8 @@ constants, array and diagnostic ordering, and always-emitted empty arrays. Apply `additionalProperties: false` recursively. Self-validate the schema, validate every snapshot, and reject malformed near-miss documents. -Run the four required gates. A scrutineer then runs CodeRabbit. Resolve all -concerns, update this ExecPlan's evidence and decisions, and commit the +Run the four required gates, then run an independent automated review. Resolve +all concerns, update this ExecPlan's evidence and decisions, and commit the milestone before proceeding. ### Milestone 2: collect source-faithful facts @@ -677,8 +673,8 @@ the minimal green change, and refactor only after the focused and wider adapter suite pass. Round-trip every complete fixture through the exact upstream tree. Recovered fixtures must always retain partial facts and classify as exit 1. -Run the four gates, then scrutineer CodeRabbit review, concern resolution, -ExecPlan update, and an atomic commit. +Run the four gates, then automated review, concern resolution, ExecPlan update, +and an atomic commit. ### Milestone 3: wire CLI, input, JSON, and process behaviour @@ -778,8 +774,8 @@ rather than an unreliable unreadable-file E2E under privileged CI. Delete `greet`, the greeting `main`, its lint exception, and `tests/stub.rs` only after replacement tests are green. Run the release-mode large/deep input -guardrail, the four gates, scrutineer CodeRabbit review, concern resolution, a -clean follow-up review, ExecPlan update, and an atomic commit. +guardrail, the four gates, automated review, concern resolution, a clean +follow-up review, ExecPlan update, and an atomic commit. ### Milestone 4: synchronize contracts and prove acceptance @@ -796,15 +792,16 @@ style guide and confirm that its Accepted status is supported by current external evidence. Add a consumer-shaped test that deserializes representative schema-v1 JSON -without linking Rust implementation types. Record the command and result for an -actual Concordat subprocess trial when that repository is available. If it is -not available, leave the ADR Proposed and record the external gap. +without linking Rust implementation types. Record a reproducible subprocess +trial from an external consumer environment. The recorded successful trial +supports the ADR's current Accepted status; future acceptance evidence must +retain both the consumer-shaped test and subprocess result. Run `make fmt` after documentation changes, followed by `make markdownlint` and `make nixie`. If the Makefile changes, also run `mbake validate Makefile`. Then -run the four required gates. Scrutineer runs the final CodeRabbit review; clear -all concerns, update this plan and its retrospective, and commit. Do not mark -the plan COMPLETE until every acceptance criterion has current evidence. +run the four required gates and an independent automated review; clear all +concerns, update this plan and its retrospective, and commit. Do not mark the +plan COMPLETE until every acceptance criterion has current evidence. ## Concrete steps @@ -846,19 +843,15 @@ make test ``` Expected successful endings include no warnings and exit status 0. Only after -all four pass may the scrutineer run: - -```shell -coderabbit review --agent -``` +all four pass may an independent automated review run. Resolve every applicable concern, rerun affected focused tests and all four -gates, rerun CodeRabbit to obtain a clean follow-up, update this document, then -commit the milestone. Never commit with a failing gate. Within a milestone, -make reviewable checkpoint commits after domain/schema, upstream contract, -rules/recipes, variables/includes/conditions, CLI/source, reporter/process, and -BDD/E2E units become independently green. Run CodeRabbit at the major milestone -boundary rather than on every checkpoint. +gates, rerun automated review to obtain a clean follow-up, update this +document, then commit the milestone. Never commit with a failing gate. Within a +milestone, make reviewable checkpoint commits after domain/schema, upstream +contract, rules/recipes, variables/includes/conditions, CLI/source, +reporter/process, and BDD/E2E units become independently green. Run automated +review at the major milestone boundary rather than on every checkpoint. For the documentation milestone, run: @@ -920,9 +913,9 @@ Acceptance requires all ADR criteria plus the following evidence: milestone and at final acceptance. - `make markdownlint` and `make nixie` pass for documentation; `mbake validate Makefile` passes if the Makefile changes. -- CodeRabbit reports no unresolved applicable concerns after deterministic +- Automated review reports no unresolved applicable concerns after deterministic gates. -- A consumer-shaped JSON contract test passes. Actual Concordat subprocess +- A consumer-shaped JSON contract test passes. Reproducible external subprocess evidence is recorded before claiming cross-repository integration or moving the ADR to Accepted. @@ -952,22 +945,22 @@ bulk-accept snapshots. ## Artefacts and notes -Firecrawl research used the authoritative 0.3.40 docs.rs source and tagged +Source research used the authoritative 0.3.40 docs.rs source and tagged upstream repository. It confirmed that the crate exports its GNU Make variant, lossless `Makefile`, parse-result type, ordinary errors, positioned errors, rules, recipes, variables, includes, conditionals, and Rowan ranges. Milestone 1 compile-checked those mappings against the exact dependency. -The Wyvern team independently found no existing abstraction to reuse and -recommended the same narrow parser-port boundary. The community-of-experts -review and scrutineer evidence must be appended here before this draft is -offered for approval. +Independent review found no existing abstraction to reuse and recommended the +same narrow parser-port boundary. Architecture review and independent +validation evidence must be appended here before this draft is offered for +approval. -The scrutineer recorded passing `git diff --check`, Markdown and spelling, -Nixie, Rust formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, -nextest, and doctest gates. Three completed CodeRabbit rounds reported 11, 9, -and 7 actionable concerns respectively; all were addressed. A later -pre-completion review completed successfully across 34 files with zero findings. +Independent validation recorded passing `git diff --check`, Markdown and +spelling, Nixie, Rust formatting, Polonius type-checking, rustdoc, Clippy, +Whitaker, nextest, and doctest gates. Three completed automated review rounds +reported 11, 9, and 7 actionable concerns respectively; all were addressed. A +later pre-completion review completed across 34 files with zero findings. The final manual CLI exercise produced `complete=0`, `recovered=1`, and `stdin=0`. Every command wrote one schema-v1 JSON document, no command wrote to @@ -997,7 +990,7 @@ below 256 MiB. An external Python subprocess invoked the release binary against a representative Makefile, decoded JSON with the standard library, asserted schema version 1 and complete status, and found `build`, `lint`, and `test`. -The successful summary was: +The following derived consumer summary is not the schema-v1 document: ```plaintext {"schema_version":1,"status":"complete","required_targets":["build","lint","test"],"language_binding":false} @@ -1058,20 +1051,20 @@ Decision log. ## Revision note -Initially revised 2026-07-13 after Wyvern, Logisphere, and CodeRabbit review to -freeze path, range, schema, parser-port, failure-output, CLI merge, security, -performance, and dependency decisions and to import and correct the OrthoConfig -0.8.0 guide. Implementation completed on 2026-07-13 with deterministic gates, -manual acceptance, performance measurements, and external Concordat and -include-boundary evidence recorded above. Pull request review remains pending. -Revised again on 2026-07-14 to inject the ambient filesystem capability at the -CLI boundary while preserving the stable source error and process diagnostic -contracts. Terminal documentation review then clarified hashing ownership and -replaced planning-time scaffold descriptions in the current repository -orientation and applied the valid CLI and parser-helper fixes. Independent -scrutineer validation passed all post-correction gates; exact terminal-diff -CodeRabbit certification remains pending in the pull request. The shared -source-reader test double was subsequently moved to a test-only common module -because Cargo does not export `cfg(test)` automatic mocks to integration-test -crates. Independent post-change repository gates passed with warnings denied -across every integration-test binary. +Initially revised 2026-07-13 after independent architecture and automated +review to freeze path, range, schema, parser-port, failure-output, CLI merge, +security, performance, and dependency decisions and to import and correct the +OrthoConfig 0.8.0 guide. Implementation completed on 2026-07-13 with +deterministic gates, manual acceptance, performance measurements, and external +consumer and include-boundary evidence recorded above. Pull request review +remains pending. Revised again on 2026-07-14 to inject the ambient filesystem +capability at the CLI boundary while preserving the stable source error and +process diagnostic contracts. Terminal documentation review then clarified +hashing ownership and replaced planning-time scaffold descriptions in the +current repository orientation and applied the valid CLI and parser-helper +fixes. Independent validation passed all post-correction gates; exact +terminal-diff automated certification remains pending in the pull request. The +shared source-reader test double was subsequently moved to a test-only common +module because Cargo does not export `cfg(test)` automatic mocks to +integration-test crates. Independent post-change repository gates passed with +warnings denied across every integration-test binary. diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 1fbc73a..257b1b4 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -1,7 +1,7 @@ # OrthoConfig user's guide > **Upstream reference:** This imported guide describes the -> [OrthoConfig repository](https://github.com/leynos/ortho-config), not the +> [OrthoConfig repository](https://github.com/owner/ortho-config), not the > makeutil workspace. Repository-relative paths, `make` commands, examples, > tests, and assets mentioned below—including Hello World and > `config/overrides.toml`—belong to that upstream repository. @@ -55,7 +55,7 @@ values from multiple sources. The core features are: The upstream OrthoConfig workspace bundles an executable Hello World example under `examples/hello_world`. It layers defaults, environment variables, and CLI flags via the derive macro; see its -[README](https://github.com/leynos/ortho-config/blob/main/examples/hello_world/README.md) +[README](https://github.com/owner/ortho-config/blob/main/examples/hello_world/README.md) for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven Development) scenarios that validate behaviour end-to-end. @@ -823,7 +823,12 @@ fn main() -> Result<(), Box> { let reference = merged_pr .reference .as_deref() - .ok_or("reference must be supplied by CLI, configuration, or environment")?; + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "reference must be supplied by CLI, configuration, or environment", + ) + })?; println!("PR reference: {reference}"); Ok(()) } @@ -833,10 +838,10 @@ A configuration file might include: ```toml [cmds.pr] -reference = "https://github.com/leynos/mxd/pull/31" +reference = "https://github.com/owner/repo/pull/31" [cmds.issue] -reference = "https://github.com/leynos/mxd/issues/7" +reference = "https://github.com/owner/repo/issues/7" ``` and environment variables could override these defaults: @@ -919,7 +924,7 @@ fn main() -> Result<(), Box> { ### Hello world walkthrough - + The `hello_world` example crate demonstrates these patterns in a compact setting. Global options such as `--recipient` or `--salutation` are resolved by @@ -1020,7 +1025,7 @@ action to perform. An enum of subcommands is annotated with `#[clap_dispatch(fn run(...))]`, and the `load_and_merge_subcommand_for` function can be called on each variant before dispatching. See the `Subcommand Configuration` section of the `OrthoConfig` -[README](https://github.com/leynos/ortho-config/blob/main/README.md) for a +[README](https://github.com/owner/ortho-config/blob/main/README.md) for a complete example. ## Error handling diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 271224d..afa12e8 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -21,6 +21,8 @@ compact and omits build output such as `target/`. │ └── release.yml +├── data/ +│ └── typos-oxendict-base.toml ├── docs/ │ ├── adrs/ │ │ └── 0001-single-file-gnu-make-parse.md @@ -72,6 +74,8 @@ compact and omits build output such as `target/`. - `.github/workflows/release.yml`: Builds and publishes binary release artefacts for the application flavour. +- `data/typos-oxendict-base.toml`: Owns the project-wide Oxford English + dictionary used to generate `typos.toml`. - `docs/`: Holds long-lived reference documentation, guides, style rules, and design material. - `docs/adrs/`: Holds sequential, stable records of architectural decisions. diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 7fa3ae9..7aeb9bb 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -131,8 +131,8 @@ argument, the wrapper panics with `pattern '' missing capture for argument ''`, making the mismatch explicit. -For cucumber-rs migration compatibility notes, see [Migration and async -patterns][migration-async-patterns]. +For cucumber-rs migration compatibility notes, see +[Migration and async patterns][migration-async-patterns]. The procedural macro implementation expands the annotated function into two parts: the original function and a wrapper function that registers the step in @@ -1040,18 +1040,20 @@ types such as `HarnessAdapter` or `ScenarioRunRequest`. adapter, the macro infers `GpuiAttributePolicy` from the canonical harness path when `attributes = ...` is omitted. -#### GPUI panic diagnostics carry scenario context +#### GPUI panic diagnostics use protected scenario context -When a step running under `GpuiHarness` panics, the harness prepends the -feature path, scenario name, and feature-file line number to the panic message -before re-raising it through `panic::resume_unwind`. The same fields are -emitted as a `tracing::error!` record (`harness_type`, `feature_path`, -`scenario_name`, `scenario_line`) and as a matching `stderr` line, so test -runners that do not collect `tracing` events still surface the scenario name on -failure. This makes a failing GPUI scenario identifiable from the `cargo test` -or `cargo nextest` output without cross-referencing libtest function names -against feature files. For a concrete regression example, see -`crates/rstest-bdd-harness-gpui/tests/scenario_name_in_logs.rs`. +When a step running under `GpuiHarness` panics, the harness prepends a stable, +non-sensitive scenario identifier to the panic message before re-raising it +through `panic::resume_unwind`. The same identifier and `harness_type` are +emitted through `tracing::error!` and a matching standard-error line, so test +runners can correlate a failure without exposing raw feature metadata. + +The feature path, scenario name, and feature-file line number are protected +values. The harness must not include them in panic messages, tracing fields, or +standard error by default. Full provenance may be emitted only when an +explicitly protected debug mode is enabled in an environment where the output +has equivalent access controls. The panic message and accompanying diagnostics +must state whether protected debug metadata was enabled. #### Stateful GPUI scenarios with durable handles @@ -1061,7 +1063,7 @@ against feature files. For a concrete regression example, see > share mutable GPUI state across BDD steps in `rstest-bdd` 0.6.0, but it > exists to work around the current `StepContext::borrow_mut` contract > selected by -> [ADR-007](https://github.com/leynos/rstest-bdd/blob/main/docs/adr-007-harness-context-injection.md). +> [ADR-007](https://github.com/owner/rstest-bdd/blob/main/docs/adr-007-harness-context-injection.md). > Sections > 2.7.6.2 and 2.7.6.5 of the design document > ([rstest-bdd design][rstest-bdd-design]) and roadmap items 12.1.x track @@ -1550,14 +1552,15 @@ target uses), each test is run in its own process. The `#[serial]` mutex is not contended across process boundaries, so the annotation is redundant-but-harmless for nextest runs. Keep it for `cargo test`; do not remove it just because nextest already isolates per-process thread-local state. -The design rationale is recorded in [design-document §2.7.6.7][ -design-runner-parallelism], and the maintainer convention is summarized in [the -developer guide][developer-serial-nextest]. +The design rationale is recorded in +[design-document §2.7.6.7][ design-runner-parallelism], and the maintainer +convention is summarized in [the developer guide][developer-serial-nextest]. When separate test processes or separate test binaries must not overlap, use a -cross-process mechanism instead of `#[serial]`. cargo-nextest [test-groups][ -nextest-test-groups] define logical mutexes across the whole nextest run. This -example makes any test whose name contains `stateful_gpui::` run one at a time: +cross-process mechanism instead of `#[serial]`. cargo-nextest +[test-groups][ nextest-test-groups] define logical mutexes across the whole +nextest run. This example makes any test whose name contains `stateful_gpui::` +run one at a time: ```toml [test-groups] @@ -1666,10 +1669,10 @@ Tests that exercise skip-heavy flows no longer need to match on enums to verify that a step or scenario stopped executing. Use `rstest_bdd::assert_step_skipped!` to unwrap a `StepExecution::Skipped` outcome, optionally constraining its message, and -`rstest_bdd::assert_scenario_skipped!` to inspect [`ScenarioStatus`][ -scenario-status] records. Both macros accept `message_absent = true` to assert -that no message was provided and substring matching to confirm that a message -contains the expected reason. +`rstest_bdd::assert_scenario_skipped!` to inspect +[`ScenarioStatus`][ scenario-status] records. Both macros accept +`message_absent = true` to assert that no message was provided and substring +matching to confirm that a message contains the expected reason. ```rust,no_run use rstest_bdd::{assert_scenario_skipped, assert_step_skipped, StepExecution}; @@ -1823,8 +1826,8 @@ synchronous steps that drive async work via `tokio::spawn_local`. Async scenarios run on Tokio's current-thread runtime. Step functions may be `async fn` and are awaited sequentially, keeping fixture borrows valid across `.await` points. Use one of the following patterns to keep async work safe and -predictable. This section summarizes the canonical guidance in [Migration and -async patterns][migration-async-patterns]. +predictable. This section summarizes the canonical guidance in +[Migration and async patterns][migration-async-patterns]. - **Prefer async fixtures:** If a step needs async data, move the async call into a fixture and inject the resolved value into the step. The scenario @@ -2775,11 +2778,11 @@ integrate acceptance criteria into their Rust test suites and to engage all three amigos in the specification process. [scenario-status]: https://docs.rs/rstest-bdd/latest/rstest_bdd/reporting/enum.ScenarioStatus.html -[adr-001]: https://github.com/leynos/rstest-bdd/blob/main/docs/adr-001-async-fixtures-and-test.md -[adr-013]: https://github.com/leynos/rstest-bdd/blob/main/docs/adr-013-adopt-whitaker-no-unwrap-or-else-panic.md -[gherkin-syntax]: https://github.com/leynos/rstest-bdd/blob/main/docs/gherkin-syntax.md#section-12-the-anatomy-of-a-feature-file -[migration-async-patterns]: https://github.com/leynos/rstest-bdd/blob/main/docs/cucumber-rs-migration-and-async-patterns.md -[rstest-bdd-design]: https://github.com/leynos/rstest-bdd/blob/main/docs/rstest-bdd-design.md -[design-runner-parallelism]: https://github.com/leynos/rstest-bdd/blob/main/docs/rstest-bdd-design.md#2767-test-runner-parallelism-and-scenario-state -[developer-serial-nextest]: https://github.com/leynos/rstest-bdd/blob/main/docs/developers-guide.md#serial-file_serial-and-nextest-test-groups +[adr-001]: https://github.com/owner/rstest-bdd/blob/main/docs/adr-001-async-fixtures-and-test.md +[adr-013]: https://github.com/owner/rstest-bdd/blob/main/docs/adr-013-adopt-whitaker-no-unwrap-or-else-panic.md +[gherkin-syntax]: https://github.com/owner/rstest-bdd/blob/main/docs/gherkin-syntax.md#section-12-the-anatomy-of-a-feature-file +[migration-async-patterns]: https://github.com/owner/rstest-bdd/blob/main/docs/cucumber-rs-migration-and-async-patterns.md +[rstest-bdd-design]: https://github.com/owner/rstest-bdd/blob/main/docs/rstest-bdd-design.md +[design-runner-parallelism]: https://github.com/owner/rstest-bdd/blob/main/docs/rstest-bdd-design.md#2767-test-runner-parallelism-and-scenario-state +[developer-serial-nextest]: https://github.com/owner/rstest-bdd/blob/main/docs/developers-guide.md#serial-file_serial-and-nextest-test-groups [nextest-test-groups]: https://nexte.st/docs/configuration/test-groups/ diff --git a/docs/terms-of-reference.md b/docs/terms-of-reference.md index 256de40..317af87 100644 --- a/docs/terms-of-reference.md +++ b/docs/terms-of-reference.md @@ -1,7 +1,7 @@ # makeutil terms of reference - **Status:** Draft v0.1 -- **Audience:** `makeutil` implementers, Concordat policy authors, and +- **Audience:** `makeutil` implementers, downstream policy authors, and integration reviewers - **Companion documents:** [Technical design](design.md) and [ADR-0001](adrs/0001-single-file-gnu-make-parse.md) @@ -11,9 +11,9 @@ `makeutil` exists to turn one GNU Makefile into deterministic, source-located structured data without evaluating or executing the file. -Its first consumer is Concordat. Concordat needs enough evidence to audit the -required Makefile targets and the binding of the Rust lint gate, while keeping -Make syntax and source-location handling outside Rego and Python. +Its first downstream policy consumer needs enough evidence to audit required +Makefile targets and the binding of the Rust lint gate, while keeping Make +syntax and source-location handling outside Rego and Python. These terms of reference define the problem and the ownership boundary. The technical design chooses the implementation shape. @@ -22,7 +22,7 @@ technical design chooses the implementation shape. The domain is static, source-faithful inspection of GNU Makefiles. `makeutil` sits between Make syntax and tools that consume ordinary JSON, including OPA, -Conftest, Concordat, tests, and future editor integrations. +Conftest, policy consumers, tests, and future editor integrations. A Makefile is an executable program. Parsing can establish which rules, variable definitions, recipes, conditionals, and include directives appear in @@ -34,12 +34,12 @@ not claim to supply GNU Make's effective runtime model. ## 3. Users and stakeholders -| Actor | Role | Need | -| ----------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- | -| Concordat policy author | Writes Rego rules over repository evidence | Receive stable facts rather than parse Make syntax in policy. | -| Concordat orchestrator | Runs policy against a repository checkout | Invoke a deterministic command and associate findings with source locations. | -| `makeutil` maintainer | Maintains the parser adapter and output contract | Keep upstream parser changes behind a narrow, tested boundary. | -| CI and fixture author | Verifies policy and parser behaviour | Exercise representative Makefiles without running their recipes. | +| Actor | Role | Need | +| --------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- | +| Policy author | Writes Rego rules over repository evidence | Receive stable facts rather than parse Make syntax in policy. | +| Policy orchestrator | Runs policy against a repository checkout | Invoke a deterministic command and associate findings with source locations. | +| `makeutil` maintainer | Maintains the parser adapter and output contract | Keep upstream parser changes behind a narrow, tested boundary. | +| CI and fixture author | Verifies policy and parser behaviour | Exercise representative Makefiles without running their recipes. | ## 4. Job to be done @@ -48,7 +48,7 @@ reports explicit source constructs and their locations, so it can make a policy decision without invoking `make`, embedding a Make parser, or relying on regular expressions over the complete file. -For the first Concordat use case, the consumer needs to answer these questions: +For the first downstream use case, the consumer needs to answer these questions: - Does the root Makefile explicitly define `build`, `test`, and `lint` targets? - Does a relevant target sit inside a conditional branch? @@ -84,7 +84,7 @@ The first release will not: - follow `include`, `-include`, or `sinclude` directives; - discover Makefiles, repositories, Git refs, or Cargo workspaces; - parse `Cargo.toml`, workflow YAML, shell syntax, or Rego; -- decide whether a repository or Makefile complies with Concordat policy; +- decide whether a repository or Makefile complies with downstream policy; - rewrite or format a Makefile; - expose Python, Go, C, or WebAssembly bindings; - support BSD Make, POSIX Make, or Microsoft NMake dialects; @@ -111,7 +111,7 @@ The first release succeeds when: - parsing a file containing `$(shell ...)`, `!=`, or hostile recipe text causes no external process or filesystem side effect; - fixture and golden tests cover the accepted contract; -- Concordat can consume the output without importing Rust or +- an external consumer can use the output without importing Rust or `makefile-lossless` types. ## 8. Constraints @@ -134,9 +134,9 @@ The first release succeeds when: - The initial Rust repository corpus uses UTF-8 root Makefiles. - The first policies can work from explicit rules and raw recipe text without a complete GNU Make evaluator. -- A one-process invocation per repository is fast enough for the first local - Concordat slice. -- Concordat will reject or mark incomplete any recovered parse rather than +- A one-process invocation per repository is fast enough for the first + downstream slice. +- The consumer will reject or mark incomplete any recovered parse rather than treating partial evidence as proof of compliance. - Include traversal and source mutation can wait until the single-file contract has proved useful against real repositories. @@ -146,7 +146,7 @@ The first release succeeds when: - Whether later commands should follow literal include paths. - Whether the project should expose a source-preserving `rewrite` command. - Whether high-volume estate scans justify a JSON Lines batch mode. -- Whether Python bindings improve Concordat performance enough to justify native +- Whether Python bindings improve consumer performance enough to justify native wheel distribution. - Whether the parser adapter should recognize a restricted set of Make functions semantically. diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index 9c91be4..ce674ed 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -3,27 +3,23 @@ # requires-python = ">=3.13" # dependencies = [] # /// -"""Generate ``typos.toml`` from the shared en-GB-oxendict dictionary. +"""Generate ``typos.toml`` from the project-owned Oxford English dictionary. -The shared dictionary is refreshed into an untracked repository-local cache -only when the authoritative copy is newer. A valid cache remains usable when -the network is unavailable, and ``typos.local.toml`` supplies the narrow -repository-specific policy that must not weaken the estate-wide base. +The dictionary is refreshed into an untracked repository-local cache only when +the authoritative copy is newer. ``typos.local.toml`` supplies the narrow +repository-specific policy that must not weaken the project-owned base. """ from pathlib import Path import typos_rollout as rollout -DEFAULT_BASE_URL = ( - "https://raw.githubusercontent.com/leynos/agent-helper-scripts/" - "refs/heads/main/data/typos-oxendict-base.toml" -) REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_BASE_SOURCE = REPOSITORY_ROOT / "data" / "typos-oxendict-base.toml" def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Dictionary: - """Load the cached shared base merged with local repository policy.""" + """Load the cached project base merged with local repository policy.""" dictionary = rollout.load_dictionary(repository / ".typos-oxendict-base.toml") local_overlay = repository / "typos.local.toml" if local_overlay.exists(): @@ -43,10 +39,10 @@ def main( output: Path | None = None, *, repository: Path = REPOSITORY_ROOT, - source: str | Path = DEFAULT_BASE_URL, + source: str | Path = DEFAULT_BASE_SOURCE, offline: bool = False, ) -> rollout.RefreshResult: - """Refresh the shared base cache and write the merged configuration.""" + """Refresh the project base cache and write the merged configuration.""" result = rollout.refresh_base( source, repository / ".typos-oxendict-base.toml", diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index bc014e0..04c4a94 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -44,7 +44,7 @@ class Dictionary: @dataclasses.dataclass(frozen=True) class RefreshResult: - """Describe whether the untracked shared dictionary cache changed.""" + """Describe whether the untracked base-dictionary cache changed.""" status: str cache: pathlib.Path @@ -77,7 +77,7 @@ def _table(document: cabc.Mapping[str, object], key: str) -> cabc.Mapping[str, o def _dictionary_from_text(text: str) -> Dictionary: - """Parse and validate shared dictionary text.""" + """Parse and validate base-dictionary text.""" document = tomllib.loads(text) schema = document.get("schema") if schema != SCHEMA_VERSION: @@ -104,12 +104,12 @@ def _dictionary_from_text(text: str) -> Dictionary: def load_dictionary(path: pathlib.Path) -> Dictionary: - """Load a validated shared dictionary from *path*.""" + """Load a validated base dictionary from *path*.""" return _dictionary_from_text(path.read_text(encoding="utf-8")) def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: - """Merge a shared dictionary with a non-conflicting local overlay.""" + """Merge a base dictionary with a non-conflicting local overlay.""" corrections = dict(base.corrections) for word, correction in local.corrections: existing = corrections.get(word) @@ -229,7 +229,7 @@ def _write_metadata( def _valid_cache(cache: pathlib.Path) -> bool: - """Return whether *cache* contains a valid shared dictionary.""" + """Return whether *cache* contains a valid base dictionary.""" try: load_dictionary(cache) except ( @@ -320,7 +320,7 @@ def _https_request( ) -> urllib.request.Request: """Build a request after constraining the shared source to HTTPS.""" if urllib.parse.urlsplit(source).scheme != "https": - message = f"shared dictionary URL must use HTTPS: {source}" + message = f"base dictionary URL must use HTTPS: {source}" raise ValueError(message) return urllib.request.Request(source, headers=dict(headers)) # noqa: S310 - HTTPS is required above. @@ -390,7 +390,7 @@ def refresh_base( """Refresh an untracked base cache when the authoritative copy is newer.""" if offline: if not _valid_cache(cache): - message = f"no cached shared dictionary at {cache}" + message = f"no cached base dictionary at {cache}" raise FileNotFoundError(message) return RefreshResult("offline-cache", cache) if isinstance(source, pathlib.Path) or "://" not in str(source): diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 2d31d94..9c544a0 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -224,7 +224,7 @@ fn read_input( "--stdin-filename is required when PATH is -", ) })?; - return read_stdin(streams.stdin) + return read_stdin(streams.stdin, &logical_path) .map(|bytes| (bytes, logical_path)) .map_err(|error| fatal(streams.stderr, error.operation(), &error.to_string())); } diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index af394b9..1104260 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -285,40 +285,45 @@ fn collect_diagnostics( source: &str, observations: &mut Vec, ) -> Result<(), ParserPortError> { - if !parsed.positioned_errors().is_empty() { - for error in parsed.positioned_errors() { - observations.push(SyntaxObservation::Diagnostic { - message: error.message.clone(), - code: error.code.clone(), - span: span(error.range, source.len())?, - }); - } - return Ok(()); + for error in parsed.positioned_errors() { + observations.push(SyntaxObservation::Diagnostic { + message: error.message.clone(), + code: error.code.clone(), + span: span(error.range, source.len())?, + }); } + let line_spans: Vec<_> = if parsed.errors().is_empty() { + Vec::new() + } else { + source + .split_inclusive('\n') + .scan(0_usize, |start, segment| { + let span = SourceSpan { + start: *start, + end: start.saturating_add(segment.trim_end_matches(['\r', '\n']).len()), + }; + *start = start.saturating_add(segment.len()); + Some(span) + }) + .collect() + }; + let end_of_source = SourceSpan { + start: source.len(), + end: source.len(), + }; for error in parsed.errors() { observations.push(SyntaxObservation::Diagnostic { message: error.message.clone(), code: None, - span: line_span(source, error.line), + span: line_spans + .get(error.line.saturating_sub(1)) + .copied() + .unwrap_or(end_of_source), }); } Ok(()) } -fn line_span(source: &str, one_based_line: usize) -> SourceSpan { - let target = one_based_line.saturating_sub(1); - let mut start = 0_usize; - let mut end = source.len(); - for (line, segment) in source.split_inclusive('\n').enumerate() { - if line == target { - end = start.saturating_add(segment.trim_end_matches(['\r', '\n']).len()); - break; - } - start = start.saturating_add(segment.len()); - } - SourceSpan { start, end } -} - fn span( range: makefile_lossless::TextRange, source_length: usize, diff --git a/src/adapters/makefile_tests.rs b/src/adapters/makefile_tests.rs index d46d8e7..672b6aa 100644 --- a/src/adapters/makefile_tests.rs +++ b/src/adapters/makefile_tests.rs @@ -4,7 +4,13 @@ use makefile_lossless::{Makefile, Parse}; use pretty_assertions::assert_eq; use rstest::rstest; -use super::{MakefileLosslessParser, assignment_operator, condition_kind, ensure_round_trip}; +use super::{ + MakefileLosslessParser, + assignment_operator, + collect_diagnostics, + condition_kind, + ensure_round_trip, +}; use crate::{ domain::AssignmentOperator, ports::{MakefileParser as _, ParserPortError, SyntaxObservation}, @@ -82,3 +88,20 @@ fn multiline_define_preserves_exact_body() { Some((AssignmentOperator::Define, "echo one \necho two\t \n")) ); } + +#[rstest] +fn all_upstream_diagnostic_channels_are_retained_for_large_sources() { + let source = "broken rule without colon\n".repeat(4_096); + let parsed = Parse::::parse_makefile(&source); + assert!(!parsed.positioned_errors().is_empty()); + assert!(!parsed.errors().is_empty()); + + let mut observations = Vec::new(); + collect_diagnostics(&parsed, &source, &mut observations) + .expect("valid upstream diagnostic spans should be retained"); + + assert_eq!( + observations.len(), + parsed.positioned_errors().len() + parsed.errors().len() + ); +} diff --git a/src/adapters/source.rs b/src/adapters/source.rs index f444e16..faf6733 100644 --- a/src/adapters/source.rs +++ b/src/adapters/source.rs @@ -79,14 +79,17 @@ pub fn read_path( read_bounded(&mut file, display_path) } -/// Read all bytes from an injected standard-input reader. +/// Read all bytes from an injected standard-input reader under `logical_path`. /// /// # Errors /// /// Returns [`SourceReadError`] when the stream fails or exceeds /// [`MAX_SOURCE_BYTES`]. -pub fn read_stdin(reader: &mut (impl std::io::Read + ?Sized)) -> Result, SourceReadError> { - read_bounded(reader, "standard input".to_owned()) +pub fn read_stdin( + reader: &mut (impl std::io::Read + ?Sized), + logical_path: &str, +) -> Result, SourceReadError> { + read_bounded(reader, logical_path.to_owned()) } fn read_bounded( diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 143718c..1746c09 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -2,7 +2,14 @@ use makeutil::{ adapters::MakefileLosslessParser, - domain::{AssignmentOperator, ConditionKind, LocationIndex, ParseStatus, SourceSpan}, + domain::{ + AssignmentOperator, + ConditionKind, + LocationIndex, + ParseStatus, + SourceSpan, + ToolIdentity, + }, parse_source, }; use pretty_assertions::assert_eq; @@ -145,9 +152,30 @@ fn assignment_operators_remain_source_faithful( fn assignment_operators_match_schema_values( #[case] operator: AssignmentOperator, #[case] expected_json: &str, -) -> Result<(), serde_json::Error> { - assert_eq!(serde_json::to_string(&operator)?, expected_json); - Ok(()) +) { + let serialized = + serde_json::to_string(&operator).expect("assignment operator should serialise"); + assert_eq!(serialized, expected_json); +} + +#[rstest] +fn parser_version_matches_manifest_pin_and_schema_constant() { + let parser_version = ToolIdentity::default().parser_version; + let expected_manifest_entry = format!(r#"makefile-lossless = "={parser_version}""#); + assert!( + include_str!("../Cargo.toml") + .lines() + .any(|line| line == expected_manifest_entry), + "Cargo.toml must pin makefile-lossless to {parser_version}" + ); + + let schema: serde_json::Value = + serde_json::from_str(include_str!("../schemas/makeutil.parse.v1.schema.json")) + .expect("schema should be valid JSON"); + assert_eq!( + schema.pointer("/$defs/tool/properties/parser_version/const"), + Some(&serde_json::Value::String(parser_version.to_owned())) + ); } #[rstest] diff --git a/tests/output_failures.rs b/tests/output_failures.rs index ce4caf6..ed751a1 100644 --- a/tests/output_failures.rs +++ b/tests/output_failures.rs @@ -70,9 +70,30 @@ fn broken_path_reader_exits_two_with_stable_operation() { ProcessCapabilities::new(&mut stdin, &mut stdout, &mut stderr, &source_reader); let outcome = run_from_with_reader(["makeutil", "parse", "Makefile"], capabilities); assert_eq!(outcome.exit_code, 2); + assert!(stdout.is_empty()); assert!(String::from_utf8_lossy(&stderr).contains("makeutil: source-read:")); } +#[rstest] +fn broken_stdin_reader_uses_logical_path_and_emits_no_json() { + let mut stdin = failing_reader(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let outcome = run_from( + ["makeutil", "parse", "--stdin-filename", "logical.mk", "-"], + &mut stdin, + &mut stdout, + &mut stderr, + ); + assert_eq!(outcome.exit_code, 2); + assert!(stdout.is_empty()); + let diagnostic = String::from_utf8_lossy(&stderr); + assert!( + diagnostic.contains("makeutil: source-read: could not read logical.mk:"), + "unexpected diagnostic: {diagnostic}" + ); +} + #[rstest] fn oversized_stdin_exits_two_with_stable_operation() { let mut stdin = std::io::repeat(b'x'); @@ -91,6 +112,10 @@ fn oversized_stdin_exits_two_with_stable_operation() { diagnostic.contains("makeutil: source-too-large:"), "unexpected diagnostic: {diagnostic}" ); + assert!( + diagnostic.contains("source Makefile exceeds"), + "unexpected diagnostic: {diagnostic}" + ); } #[rstest] diff --git a/tests/snapshots/report_schema__recovered_output_has_stable_json.snap b/tests/snapshots/report_schema__recovered_output_has_stable_json.snap index aa7ef49..ba71906 100644 --- a/tests/snapshots/report_schema__recovered_output_has_stable_json.snap +++ b/tests/snapshots/report_schema__recovered_output_has_stable_json.snap @@ -29,6 +29,18 @@ expression: report "end_line": 2, "end_column": 16 } + }, + { + "message": "expected ':'", + "code": null, + "location": { + "start_byte": 64, + "end_byte": 64, + "start_line": 5, + "start_column": 1, + "end_line": 5, + "end_column": 1 + } } ] }, diff --git a/tests/source_adapter.rs b/tests/source_adapter.rs index 358f733..b6b6d33 100644 --- a/tests/source_adapter.rs +++ b/tests/source_adapter.rs @@ -66,26 +66,21 @@ fn path_source_over_limit_is_rejected() -> googletest::Result<()> { } #[rstest] -fn standard_input_over_limit_is_rejected() -> googletest::Result<()> { +fn standard_input_over_limit_is_rejected() { let mut stdin = std::io::repeat(b'x'); - let error = read_stdin(&mut stdin).expect_err("oversized stdin should fail"); - verify_that!(error.operation(), eq("source-too-large"))?; - verify_that!( - matches!( - error, - SourceReadError::TooLarge { - limit: MAX_SOURCE_BYTES, - .. - } - ), - eq(true) - ) + let error = read_stdin(&mut stdin, "logical.mk").expect_err("oversized stdin should fail"); + assert_eq!(error.operation(), "source-too-large"); + let SourceReadError::TooLarge { path, limit } = error else { + panic!("oversized stdin should report a source-too-large error"); + }; + assert_eq!(path, "logical.mk"); + assert_eq!(limit, MAX_SOURCE_BYTES); } #[rstest] fn standard_input_at_limit_is_accepted() -> googletest::Result<()> { let limit = u64::try_from(MAX_SOURCE_BYTES).expect("source limit should fit u64"); let mut stdin = std::io::repeat(b'x').take(limit); - let bytes = read_stdin(&mut stdin)?; + let bytes = read_stdin(&mut stdin, "logical.mk")?; verify_that!(bytes.len(), eq(MAX_SOURCE_BYTES)) } From ba101f7a2b96883daf3452a371a65577765c6018 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 24 Jul 2026 20:00:02 +0200 Subject: [PATCH 22/29] Fix harness guide error handling Replace the contradictory `expect` call with explicit success and failure arms so the example follows the documented pedantic lint policy. --- docs/rstest-bdd-users-guide.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 7aeb9bb..cae0596 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -2047,7 +2047,10 @@ let request = ScenarioRunRequest::new( ); let harness = MyHarness; -assert_eq!(harness.run(request).expect("harness should not fail"), "ok"); +match harness.run(request) { + Ok(value) => assert_eq!(value, "ok"), + Err(error) => panic!("harness failed unexpectedly: {error}"), +} ``` Harnesses that need framework resources can choose a non-unit context type and From a2f6c44872e916cefa31d8092af059a05e06d4c4 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 25 Jul 2026 15:23:00 +0200 Subject: [PATCH 23/29] Remove operational validation provenance Replace host-specific validation history with checked-in contract evidence while preserving the parser, recovery, security, bounded-input, and schema requirements. Add a documentation-gate search that prevents the prohibited provenance markers from returning. --- Makefile | 10 +- docs/developers-guide.md | 5 + .../adr-0001-single-file-gnu-make-parse.md | 385 ++++++------------ tests/corpus.rs | 13 +- 4 files changed, 153 insertions(+), 260 deletions(-) diff --git a/Makefile b/Makefile index a7bfd82..851ea11 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint spelling nixie audit rust-audit +.PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint spelling provenance nixie audit rust-audit SHELL := bash @@ -69,11 +69,19 @@ check-fmt: ## Verify formatting markdownlint: ## Lint Markdown files $(MDLINT) '**/*.md' +$(MAKE) spelling + +$(MAKE) provenance spelling: ## Enforce en-GB-oxendict spelling in Markdown prose uv run scripts/generate_typos_config.py find . -type f -name '*.md' -not -path './target/*' -print0 | \ xargs -0 $(TYPOS) --config typos.toml --force-exclude +provenance: ## Reject non-reproducible operational provenance + ! git grep -n -i -E \ + 'Concordat|/data/|pg-embed|Parabellum|compatibility audit|external consumer environment|subprocess trial|independent validation' \ + -- ':(exclude)Makefile' + ! git grep -n -i 'leynos/' -- '*.md' '*.rs' '*.py' '*.toml' \ + ':(exclude)Cargo.toml' + nixie: ## Validate Mermaid diagrams $(NIXIE) --no-sandbox diff --git a/docs/developers-guide.md b/docs/developers-guide.md index cca4623..f01397b 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -139,6 +139,11 @@ The tracked `typos.toml` is generated from the project-owned dictionary in when the tracked source is newer, regenerate the configuration, and check maintained prose. +`make provenance` rejects personal repository references, local paths, named +operational projects, and claims of validation that cannot be reproduced from +the repository. `make markdownlint` includes this check. Generic consumer +contracts and technical dependency coordinates remain permitted. + ### Security audit ignores Security audit jobs may set `CARGO_AUDIT_IGNORES` for narrowly scoped RustSec diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 9f37420..49455e0 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -66,10 +66,9 @@ effects. - Tests must not mutate the environment of the shared test process. Set environment variables only on child processes if a case requires them. - Run `make check-fmt`, `make typecheck`, `make lint`, and `make test` after - every major milestone, then run an independent automated review. Resolve all - deterministic gate failures before requesting review. Resolve applicable - review concerns as a separate action before proceeding. -- Commit each accepted milestone atomically after its gates and review pass. + every major milestone. Resolve all deterministic gate failures before + proceeding. +- Commit each accepted milestone atomically after its gates pass. - Update user-facing behaviour in [the user's guide](../users-guide.md), internal interfaces and ownership in [the design](../design.md), repository paths in [the repository layout](../repository-layout.md), and contributor @@ -127,20 +126,19 @@ stop and resolve the conflict before editing `Cargo.toml`. approval as approval of one scoped exception, document it in the design and developer guide, and do not generalize it. - Risk: a schema represented only by Rust structs and snapshots is difficult - for an external consumer to validate independently. Severity: medium. - Likelihood: medium. Mitigation: add a checked JSON Schema artefact for schema - version 1 and test representative complete and recovered documents against it. + to validate without linking the implementation. Severity: medium. Likelihood: + medium. Mitigation: add a checked JSON Schema artefact for schema version 1 + and test representative complete and recovered documents against it. - Risk: tests might claim “no execution” while only testing ordinary recipes. Severity: high. Likelihood: medium. Mitigation: end-to-end hostile fixtures contain `$(shell ...)`, `!=`, recipe commands, dynamic includes, and literal includes that would create a sentinel if evaluated or opened. Assert the sentinel remains absent. -- Risk: the external integration criterion is outside this repository. - Severity: medium. Likelihood: high. Mitigation: provide a consumer-shaped - deserialization fixture and record a reproducible subprocess trial as - evidence required before implementation is declared complete; do not - fabricate cross-repository proof. Outcome: a trial from an external consumer - environment passed. +- Risk: compatibility claims could depend on evidence outside this repository. + Severity: medium. Likelihood: high. Mitigation: require a consumer-shaped + deserialization test, checked JSON Schema validation, and documented CLI, + stream, and exit-code contracts. Acceptance depends only on checked-in + artefacts and repeatable repository gates. - Risk: strict lints and code-size limits may encourage premature abstraction. Severity: medium. Likelihood: medium. Mitigation: keep modules cohesive, sweep for equivalent helpers before every extraction, and add a trait only at @@ -149,20 +147,14 @@ stop and resolve the conflict before editing `Cargo.toml`. ## Progress -- [x] (2026-07-13) Created the Leta workspace and mapped the scaffold, ADR, - design, documentation, test guidance, and build gates through independent - review. +- [x] (2026-07-13) Mapped the scaffold, ADR, design, documentation, test + guidance, and build gates. - [x] (2026-07-13) Confirmed upstream `makefile-lossless` 0.3.40 exposes a lossless tree, recovered results, and ordinary and positioned diagnostics. -- [x] (2026-07-13) Imported the OrthoConfig user's guide from - `../../ortho-config/docs/users-guide.md` and indexed it. -- [x] (2026-07-13) Completed an independent architecture review and revised the - design to freeze logical-path spelling, construct ranges, ordinal ownership, - diagnostics, failure output, and observability before approval. -- [x] (2026-07-13) Passed all planning milestone deterministic gates and - resolved every actionable concern from three automated review rounds. -- [x] (2026-07-13) Obtained a clean automated follow-up; the final - pre-completion review examined 34 files and reported zero findings. +- [x] (2026-07-13) Imported and indexed the OrthoConfig user's guide. +- [x] (2026-07-13) Revised the design to freeze logical-path spelling, + construct ranges, ordinal ownership, diagnostics, failure output, and + observability before approval. - [x] (2026-07-13) Obtained explicit approval of this ExecPlan, including the exact parser pin exception and schema/path decisions. - [x] (2026-07-13) Milestone 1: proved upstream contracts and froze the @@ -171,72 +163,37 @@ stop and resolve the conflict before editing `Cargo.toml`. recovered output against the fixture corpus. - [x] (2026-07-13) Milestone 3: implemented OrthoConfig CLI, source, JSON, and process adapters with behavioural and end-to-end validation. -- [x] (2026-07-13) Fixed `!=` lexing on fork branch - `fix-shell-assignment-operator`, validated its 472 unit tests and 98 - doctests, and pinned immutable commit +- [x] (2026-07-13) Fixed `!=` lexing in the parser dependency and pinned commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` through `[patch.crates-io]`. -- [x] (2026-07-13) Passed the complete deterministic makeutil gate set after - applying the patch; independent validation repeated every gate and automated - review completed with zero findings across 34 reviewed files. - [x] (2026-07-13) Added a consumer-owned schema-v1 deserialization test with focused red/green and Clippy evidence. -- [x] (2026-07-13) Completed the manual CLI acceptance exercise with path, - recovered, and stdin exit codes `0`, `1`, and `0` respectively. -- [x] (2026-07-13) Measured exact-size 1, 5, and 10 MiB inputs and 256 nested - conditionals in release mode; every run remained inside the elapsed-time and - memory guardrails. -- [x] (2026-07-13) Ran the release binary from an external Python 3.13 consumer, - decoded schema v1 without a Rust binding, and found its required `build`, - `lint`, and `test` targets in a complete parse. -- [x] (2026-07-13) Used `strace` to prove that existing literal and dynamic - include paths were reported but never opened. -- [x] (2026-07-13) Milestone 4: synchronized contracts, completed all acceptance - exercises, and passed every deterministic gate under independent validation. -- [x] (2026-07-14) Reviewed the terminal diff and applied valid fixes for +- [x] (2026-07-13) Added checked-in path, recovered, stdin, hostile-input, + include-reporting, and bounded-input acceptance coverage. +- [x] (2026-07-13) Milestone 4: synchronized contracts and repository + acceptance evidence. +- [x] (2026-07-14) Applied fixes for trailing variable whitespace, recipe-modifier ordering, closed conditional kinds, focused CLI helpers, and documentation drift. The focused whitespace - and modifier-order tests supplied red evidence. The complete deterministic - gate set then passed, and independent validation confirmed 45 of 45 tests, - two passing doctests with one intentionally ignored, and clean formatting, - Polonius type-checking, lint, documentation, diagram, and diff checks. + and modifier-order tests supplied red evidence. - [x] (2026-07-14) Injected ambient filesystem access at the CLI composition boundary. Red compilation proved the `SourceReader` and `run_from_with_reader` seams were absent; focused source-adapter, output- - failure, and BDD tests passed. The terminal repository gates then passed 49 - of 49 tests, two doctests with one intentionally ignored, and clean - formatting, Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, - spelling, Mermaid, and diff checks. + failure, and BDD tests passed. - [x] (2026-07-14) Corrected documentation ownership and orientation drift and applied the valid fatal CLI helper and private `collect_items` constructor - fixes found during terminal review. Independent validation confirmed 49 of 49 - tests, two passing doctests with one intentionally ignored, and clean - `make check-fmt`, `make typecheck`, `make lint`, `make test`, rustdoc, - Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. + fixes found during contract reconciliation. - [x] (2026-07-14) Replaced duplicated integration-test source readers with one `mockall` definition under `tests/common`, keeping mock code out of the - production library. Independent validation confirmed 49 of 49 tests, two - passing doctests with one intentionally ignored, and clean formatting, - Polonius type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, - Mermaid, and diff checks. All targets compiled with warnings denied and no - unused test helper. + production library. - [x] (2026-07-16) Reconciled ADR-0001's accepted date in the documentation index and repository layout and made the imported GPUI reset snippet's hidden state type match its field accesses. Added behavioural scenarios for invalid - invocation, help, and version, and shared the all-facts report fixture. The - independent validation confirmed 54 of 54 tests, two passing doctests with - one intentionally ignored, and clean formatting, Polonius type-checking, - rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and diff checks. + invocation, help, and version, and shared the all-facts report fixture. - [x] (2026-07-16) Added direct regression coverage for the concrete parser's round-trip mismatch guard and for invalid-span and split-UTF-8-boundary - `LocationError` paths. Terminal validation passed 59 of 59 tests, two - doctests with one intentionally ignored, and clean formatting, Polonius - type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and - diff checks. + `LocationError` paths. - [x] (2026-07-16) Closed the `AssignmentOperator` contract and enforced the - status/diagnostics schema invariant. Independent validation confirmed 72 of - 72 tests, three passing doctests with one intentionally ignored, unchanged - snapshots, and clean formatting, Polonius type-checking, rustdoc, Clippy, - Whitaker, Markdown, spelling, Mermaid, and diff checks. + status/diagnostics schema invariant. - [x] (2026-07-18) Added one shared bounded-read implementation for path and standard-input sources, accepting at most 16 MiB and reporting larger sources through the stable `source-too-large` fatal operation. @@ -247,18 +204,11 @@ stop and resolve the conflict before editing `Cargo.toml`. - [x] (2026-07-18) Propagated clap help/version display write failures as `stdout-write`, and added focused coverage for failed displays alongside the existing successful black-box help and version cases. -- [x] (2026-07-18) Added a dedicated external multiline `define` fixture with +- [x] (2026-07-18) Added a dedicated multiline `define` fixture with embedded newlines and trailing whitespace, and asserted its exact domain representation through the concrete parser and domain contract suite. -- [x] (2026-07-18) Independent validation confirmed 82 of 82 tests, three - passing doctests with one intentionally ignored, clean formatting, Polonius - type-checking, rustdoc, Clippy, Whitaker, Markdown, spelling, Mermaid, and - diff checks. It also confirmed that the lockfile and `typos.toml` are - unchanged and that no-default feature resolution excludes - `ortho_config/serde_json`. -- [ ] Obtain automated certification of the exact terminal diff through the - pull request. The user approved deferral while the review service was - temporarily unavailable. +- [x] (2026-07-18) Added contract coverage proving that no-default feature + resolution excludes `ortho_config/serde_json`. ## Surprises & discoveries @@ -281,9 +231,8 @@ stop and resolve the conflict before editing `Cargo.toml`. - Observation: `makefile-lossless` 0.3.40 documents `!=` as an assignment operator, but parses valid GNU Make `A != printf seven` as recovered rule fragments with diagnostics and exposes no `VariableDefinition`. Evidence: the - focused `assignment_operators_remain_source_faithful::case_7` test and a live - CLI reproduction both produce zero variable facts; independent validation - reproduced the failure. Impact: this triggers the approved upstream stop + focused `assignment_operators_remain_source_faithful::case_7` test produces + zero variable facts. Impact: this triggers the approved upstream stop condition. The exact pin cannot satisfy the source-faithful variable contract without an upstream fix, a separately approved narrow fallback parser, or an explicit scope reduction. @@ -293,42 +242,34 @@ stop and resolve the conflict before editing `Cargo.toml`. changes that set and adds lexer and lossless AST regression tests. Impact: the existing adapter now reports shell assignments source-faithfully without a makeutil-specific parser fallback or vendored crate. -- Observation: the manual acceptance command named `complete.mk`, but the - committed complete fixture is `all-facts.mk`. Evidence: the fixture corpus - contains `all-facts.mk` and `recovered.mk`; the corrected command produced a - complete schema-v1 report. Impact: the command below now uses the real - fixture path. +- Observation: the complete fixture is named `all-facts.mk`. Evidence: the + fixture corpus contains `all-facts.mk` and `recovered.mk`. Impact: repository + commands use the tracked fixture path. - Observation: the consumer-shaped test initially expected an `all` target, while the representative fixture defines `check`. Evidence: the focused red run failed with a clear `check` versus `all` diff; changing only the consumer expectation made the focused test and Clippy pass. Impact: this supplies honest red/green evidence without changing production behaviour. -- Observation: focused review tests showed that trimming a variable value lost +- Observation: focused tests showed that trimming a variable value lost source-faithful trailing whitespace and that upstream recipe accessors did not recognize every ordering of leading `@`, `-`, and `+` modifiers. Evidence: `variable_values_preserve_trailing_whitespace` and `recipe_modifier_order_is_semantic` failed before their narrow adapter fixes. Impact: raw values now remain untrimmed, and one adapter-private scanner derives all three recipe flags without widening the parser port. -- Observation: a review finding claimed the file reader did not use a - capability-oriented boundary, but `read_path` already used +- Observation: `read_path` used `cap_std::fs_utf8::File::open_ambient` with explicit ambient authority. - Evidence: `src/adapters/source.rs` owns that call and maps its open and read - failures into `SourceReadError`. Impact: the finding was stale and required - no source-reader change at that review milestone. The user subsequently - requested a stronger composition rule: ambient authority must be resolved - only by the CLI and injected into `read_path`. Impact: the explicit new - requirement supersedes the earlier no-change conclusion without changing - error or CLI contracts. + Evidence: `src/adapters/source.rs` maps open and read failures into + `SourceReadError`. Impact: ambient authority is now resolved only by the CLI + and injected into `read_path`, without changing error or CLI contracts. - Observation: the claimed duplicate generated header in `typos.toml` was stale. Evidence: the file contains one two-line header emitted verbatim by `scripts/typos_rollout.py`. Impact: generated spelling policy required no manual edit. -- Observation: a tracing and metrics warning did not apply to this approved - one-shot CLI slice. Evidence: stable operation identifiers are its documented - observability surface, success and recovered parsing keep stderr empty, and - the design explicitly defers metrics. Impact: no subscriber, recorder, or new - telemetry dependency was added during terminal review. +- Observation: stable operation identifiers are the documented observability + surface for this one-shot CLI slice; success and recovered parsing keep + stderr empty, and the design explicitly defers metrics. Impact: no + subscriber, recorder, or telemetry dependency is required. - Observation: schema v1 already enumerated assignment-operator strings, while the runtime carried an unrestricted string from the upstream adapter. Impact: an upstream value outside the schema could be serialized into a report that @@ -351,119 +292,105 @@ stop and resolve the conflict before editing `Cargo.toml`. failures. Impact: display output must use the same checked write semantics as report output, with focused failure injection and black-box success tests. - Observation: multiline `define` parsing and trailing assignment whitespace - had separate tests, but no external fixture combined the two properties. - Impact: a dedicated fixture must prove exact raw-body preservation through - the full parser and application assembly path. + had separate tests, but no fixture combined the two properties. Impact: a + dedicated fixture must prove exact raw-body preservation through the full + parser and application assembly path. ## Decision log -- Decision: defer exact terminal-diff automated certification to the pull - request after the review service first rate-limited and then required - unavailable browser authentication during a temporary service outage. - Rationale: the exact diff passed every independent deterministic gate, the - immediately preceding review was clean, and approval to wait for pull-request - review was recorded rather than blocking the commit. Date/Author: 2026-07-13 - / Project maintainers. - - Decision: generate large performance fixtures ephemerally and check their exact byte lengths with `stat` rather than commit 16 MiB of repetitive test data. Rationale: fixed `all:` and newline framing around repeated `a` bytes produces valid, deterministic rule fixtures while keeping the repository - small; the measured command fails before timing if any size differs. Date/ - Author: 2026-07-13 / Project maintainers. + small; the guardrail command fails before timing if any size differs. Date: + 2026-07-13. - Decision: patch crates.io resolution to immutable fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa` while retaining the approved exact 0.3.40 version requirement. Rationale: the minimal upstream-shaped fix adds the missing lexer start character and regression coverage without vendoring, changing makeutil policy, or exposing a mutable branch reference. Retire the - patch when an adopted upstream release contains the fix. Date/Author: - 2026-07-13 / Project maintainers. + patch when an adopted upstream release contains the fix. Date: 2026-07-13. - Decision: apply hexagonal architecture only at meaningful volatility and side-effect boundaries. Rationale: domain facts, locations, ordering, and parse outcome classification need pure tests; `makefile-lossless`, CLI parsing, filesystem access, and JSON output are adapters. Repositories, event buses, CQRS layers, and adapter-to- adapter traits would add ceremony without - protecting a real boundary. Date/Author: 2026-07-13 / Project maintainers. + protecting a real boundary. Date: 2026-07-13. - Decision: define one domain-owned `MakefileParser` port and keep upstream CST observations on the adapter side. Rationale: the young parser crate is the principal volatile dependency. The port returns makeutil-owned facts and diagnostics so upstream APIs cannot leak into schema or application policy. - Date/Author: 2026-07-13 / Project maintainers. + Date: 2026-07-13. - Decision: use property testing for `LocationIndex`, not Kani or Verus. Rationale: arbitrary UTF-8, newline layouts, and valid byte spans form a natural generative invariant. There is no bounded concurrent/state machine model for Kani and no introduced lemma or contractual business theorem that would make a substantive Verus proof possible. Adding either would be - performative rather than rigorous. Date/Author: 2026-07-13 / Project - maintainers. + performative rather than rigorous. Date: 2026-07-13. - Decision: provide JSON Schema Draft 2020-12 as a checked consumer artefact. Rationale: schema version 1 is the stable integration contract and must be - independently machine-readable; Rust structs and snapshots alone are not an - adequate subprocess contract. Date/Author: 2026-07-13 / Project maintainers. + machine-readable without linking implementation types; Rust structs and + snapshots alone are not an adequate subprocess contract. Date: 2026-07-13. - Decision: use OrthoConfig 0.8.x for the `parse` subcommand while keeping input selection explicit and unlayered. Rationale: the imported guide is the requested CLI/configuration reference, but ADR-0001 allows no implicit path or discovery. OrthoConfig supplies typed CLI derivation and preserves help/version display exits; it must not add environment or file defaults for - `PATH` or `--stdin-filename`. Date/Author: 2026-07-13 / Project maintainers. + `PATH` or `--stdin-filename`. Date: 2026-07-13. - Decision: preserve exact logical path spelling and use the complete construct-range rules in `docs/design.md` section 6.2. Rationale: callers need stable source slices and reproducible JSON. Deferring these choices until adapter implementation would make plan approval meaningless and - accidentally turn upstream accessor choices into schema policy. Date/Author: - 2026-07-13 / Independent planning review. + accidentally turn upstream accessor choices into schema policy. Date: + 2026-07-13. - Decision: let the parser adapter return ordered makeutil-owned observations and source spans; keep round-trip bytes in adapter tests only. Rationale: location conversion, the ordinal ordering invariant, and status are makeutil-owned policy; `parse_source` and its `ReportAssembly` fact collector assign ordinals, while exact-byte hashing is application-service policy. Upstream CST renderings and error types must not leak through the - domain-owned port. Date/Author: 2026-07-13 / Independent planning review. - Ownership wording clarified on 2026-07-18 during terminal documentation - review. + domain-owned port. Date: 2026-07-13. Ownership wording clarified on + 2026-07-18. - Decision: cap each path or standard-input source at an inclusive 16 MiB by composing both adapters through one private bounded-read helper. The helper is source-adapter implementation detail, not a port or general I/O utility. Rationale: one policy prevents input-dependent memory growth and keeps error - classification identical across both input modes. Date/Author: 2026-07-18 / - Independent review. + classification identical across both input modes. Date: 2026-07-18. - Decision: treat clap display writes as process output subject to `stdout-write`, while preserving clap's normal stream and exit-zero semantics when the complete display is written. Rationale: help and version output are externally observable process behaviour and cannot silently discard an I/O - failure. Date/Author: 2026-07-18 / Independent review. + failure. Date: 2026-07-18. - Decision: make makeutil's `serde_json` feature the sole switch for OrthoConfig's JSON integration and disable OrthoConfig default features. Rationale: feature ownership stays visible at the application manifest, and - `--no-default-features` has predictable dependency behaviour. Date/Author: - 2026-07-18 / Independent review. -- Decision: keep the multiline `define` regression as external Makefile input + `--no-default-features` has predictable dependency behaviour. Date: + 2026-07-18. +- Decision: keep the multiline `define` regression as a Makefile fixture and exercise it through the concrete parser and application service. Rationale: the contract concerns exact source bytes across the adapter - boundary, so an inline domain-only case cannot prove it. Date/Author: - 2026-07-18 / Independent review. + boundary, so an inline domain-only case cannot prove it. Date: 2026-07-18. - Decision: serialize to memory before stdout and permit partial stdout only when the operating system accepts a prefix before an output failure. Rationale: the process can prevent serialization failures from writing JSON, - but cannot retract accepted bytes after a broken pipe or partial write. - Date/Author: 2026-07-13 / Independent planning review. -- Decision: keep review-driven helpers at their narrowest validated ownership + but cannot retract accepted bytes after a broken pipe or partial write. Date: + 2026-07-13. +- Decision: keep helpers at their narrowest validated ownership boundary. `ConditionKind` is the closed domain/port representation consumed by observations and reports; the makefile adapter alone owns the private leading-recipe-modifier scanner; and CLI extraction, production, and emission helpers remain private to the CLI adapter. Rationale: these boundaries remove stringly typed drift and order-sensitive defects without creating reusable ports for implementation details. Permitted call sites and reuse policy are - recorded in `docs/developers-guide.md`. Date/Author: 2026-07-14 / Independent - review. + recorded in `docs/developers-guide.md`. Date: 2026-07-14. - Decision: represent schema-v1 assignment operators with the closed, domain-owned `AssignmentOperator` enum shared by the parser port and report model. The empty representation is reserved for a `define` block without an assignment token. Rationale: the producer must reject upstream drift before - serialization rather than emit JSON outside the checked schema. Date/Author: - 2026-07-16 / Independent review. + serialization rather than emit JSON outside the checked schema. Date: + 2026-07-16. - Decision: define `SourceReader` in the source adapter as a narrow capability interface, not a domain port. `read_path` owns byte collection and `SourceReadError` classification; `run_from` alone constructs the @@ -471,8 +398,8 @@ stop and resolve the conflict before editing `Cargo.toml`. and embedded composition through one `ProcessCapabilities` value. Rationale: this removes ambient authority from the reusable read function without transplanting filesystem concerns into the domain, introducing directory/ - include semantics, or exceeding the repository's four-argument limit. Date/ - Author: 2026-07-14 / Project maintainers. + include semantics, or exceeding the repository's four-argument limit. Date: + 2026-07-14. - Decision: share a `MockSourceReader` definition under `tests/common` rather than derive it on the production trait. Rationale: a `cfg_attr(test, automock)` type is not exported when the library is compiled @@ -480,8 +407,7 @@ stop and resolve the conflict before editing `Cargo.toml`. definition removes duplicated readers without adding `mockall`, a public test-support feature, or generated mocks to the production surface. Keep the failing stream in a separate shared file included only by suites that use it, - so warnings remain denied without suppressions. Date/Author: 2026-07-14 / - Project maintainers. + so warnings remain denied without suppressions. Date: 2026-07-14. ## Outcomes & retrospective @@ -489,15 +415,13 @@ The implementation now exposes the approved single-file parse contract through a capability-safe CLI and stable schema-v1 JSON. Unit, property, snapshot, BDD, and end-to-end tests cover complete, recovered, fatal, and inert-source paths. The forked parser fix restores source-faithful `!=` assignments without a -makeutil-specific fallback. Manual CLI acceptance, release-mode guardrails, and -the external consumer and include-boundary trials all pass. Independent -validation repeated every deterministic gate. The implementation of ADR-0001's +makeutil-specific fallback. Checked-in tests prove consumer-shaped schema +deserialization, complete and recovered stream behaviour, inert hostile input, +include non-traversal, and bounded input. The implementation of ADR-0001's single-file GNU Make parse slice is complete. Ambient filesystem authority is -now composed once at the CLI boundary and injected through -`ProcessCapabilities`; fake readers prove the source-open and source-read -contracts without filesystem access. Exact terminal-diff automated -certification is deferred to the pull request because the review service became -unavailable, as explicitly approved by the user. +composed once at the CLI boundary and injected through `ProcessCapabilities`; +fake readers prove the source-open and source-read contracts without filesystem +access. ## Context and orientation @@ -537,8 +461,7 @@ The implementing agent must load the `leta` skill for semantic navigation, the `hexagonal-architecture` skill for boundary checks, and the `execplans` skill to keep this document current. Research authoritative upstream sources only when an API, format, or prior-art gap remains after local documentation and -exact dependency source inspection. Use an independent design review for -substantive architecture decisions. +exact dependency source inspection. The intended narrow dependency flow is: @@ -634,9 +557,8 @@ constants, array and diagnostic ordering, and always-emitted empty arrays. Apply `additionalProperties: false` recursively. Self-validate the schema, validate every snapshot, and reject malformed near-miss documents. -Run the four required gates, then run an independent automated review. Resolve -all concerns, update this ExecPlan's evidence and decisions, and commit the -milestone before proceeding. +Run the four required gates, update this ExecPlan's evidence and decisions, and +commit the milestone before proceeding. ### Milestone 2: collect source-faithful facts @@ -673,8 +595,7 @@ the minimal green change, and refactor only after the focused and wider adapter suite pass. Round-trip every complete fixture through the exact upstream tree. Recovered fixtures must always retain partial facts and classify as exit 1. -Run the four gates, then automated review, concern resolution, ExecPlan update, -and an atomic commit. +Run the four gates, update the ExecPlan, and create an atomic commit. ### Milestone 3: wire CLI, input, JSON, and process behaviour @@ -774,8 +695,7 @@ rather than an unreliable unreadable-file E2E under privileged CI. Delete `greet`, the greeting `main`, its lint exception, and `tests/stub.rs` only after replacement tests are green. Run the release-mode large/deep input -guardrail, the four gates, automated review, concern resolution, a clean -follow-up review, ExecPlan update, and an atomic commit. +guardrail and the four gates, update the ExecPlan, and create an atomic commit. ### Milestone 4: synchronize contracts and prove acceptance @@ -788,20 +708,20 @@ ownership, port/adapter rules, helper reuse policy, fixtures, snapshots, exact parser upgrade gate, and the test-first workflow. Update `docs/repository-layout.md` for source modules, `schemas/`, features, fixtures, snapshots, and end-to-end tests. Reconcile ADR-0001 with the documentation -style guide and confirm that its Accepted status is supported by current -external evidence. +style guide and confirm that its Accepted status is supported by checked-in, +repeatable repository evidence. Add a consumer-shaped test that deserializes representative schema-v1 JSON -without linking Rust implementation types. Record a reproducible subprocess -trial from an external consumer environment. The recorded successful trial -supports the ADR's current Accepted status; future acceptance evidence must -retain both the consumer-shaped test and subprocess result. +without linking Rust implementation types. Retain JSON Schema validation and +end-to-end CLI tests for the documented stream and exit-code contract. These +checked-in tests support the ADR's Accepted status without relying on evidence +from another repository or runtime environment. Run `make fmt` after documentation changes, followed by `make markdownlint` and `make nixie`. If the Makefile changes, also run `mbake validate Makefile`. Then -run the four required gates and an independent automated review; clear all -concerns, update this plan and its retrospective, and commit. Do not mark the -plan COMPLETE until every acceptance criterion has current evidence. +run the four required gates, update this plan and its retrospective, and +commit. Do not mark the plan COMPLETE until every acceptance criterion has +current repository evidence. ## Concrete steps @@ -842,16 +762,13 @@ make lint make test ``` -Expected successful endings include no warnings and exit status 0. Only after -all four pass may an independent automated review run. +Expected successful endings include no warnings and exit status 0. Resolve every applicable concern, rerun affected focused tests and all four -gates, rerun automated review to obtain a clean follow-up, update this -document, then commit the milestone. Never commit with a failing gate. Within a -milestone, make reviewable checkpoint commits after domain/schema, upstream -contract, rules/recipes, variables/includes/conditions, CLI/source, -reporter/process, and BDD/E2E units become independently green. Run automated -review at the major milestone boundary rather than on every checkpoint. +gates, update this document, then commit the milestone. Never commit with a +failing gate. Within a milestone, make reviewable checkpoint commits after +domain/schema, upstream contract, rules/recipes, variables/includes/conditions, +CLI/source, reporter/process, and BDD/E2E units become independently green. For the documentation milestone, run: @@ -863,7 +780,7 @@ make nixie If the milestone changes `Makefile`, also run `mbake validate Makefile`. -The final manual acceptance exercise is: +The documented command contract can be reproduced with: ```shell cargo build --bin makeutil @@ -913,11 +830,10 @@ Acceptance requires all ADR criteria plus the following evidence: milestone and at final acceptance. - `make markdownlint` and `make nixie` pass for documentation; `mbake validate Makefile` passes if the Makefile changes. -- Automated review reports no unresolved applicable concerns after deterministic - gates. -- A consumer-shaped JSON contract test passes. Reproducible external subprocess - evidence is recorded before claiming cross-repository integration or moving - the ADR to Accepted. +- A consumer-shaped JSON contract test passes without linking implementation + types. +- `make provenance` confirms that maintained text contains no prohibited + operational-validation markers. Red-Green-Refactor evidence must be appended to `Progress` for each milestone: the exact red command and expected failure, the green command and pass, and the @@ -951,50 +867,22 @@ lossless `Makefile`, parse-result type, ordinary errors, positioned errors, rules, recipes, variables, includes, conditionals, and Rowan ranges. Milestone 1 compile-checked those mappings against the exact dependency. -Independent review found no existing abstraction to reuse and recommended the -same narrow parser-port boundary. Architecture review and independent -validation evidence must be appended here before this draft is offered for -approval. - -Independent validation recorded passing `git diff --check`, Markdown and -spelling, Nixie, Rust formatting, Polonius type-checking, rustdoc, Clippy, -Whitaker, nextest, and doctest gates. Three completed automated review rounds -reported 11, 9, and 7 actionable concerns respectively; all were addressed. A -later pre-completion review completed across 34 files with zero findings. - -The final manual CLI exercise produced `complete=0`, `recovered=1`, and -`stdin=0`. Every command wrote one schema-v1 JSON document, no command wrote to -standard error, and the reports classified their parse status as expected. - -The include-boundary exercise created existing `literal.mk` and `dynamic.mk` -files next to the input, traced `openat` and `openat2`, and asserted that -neither include path occurred in the syscall log. The binary reported both -includes in a complete parse; the result was `include_opened=false`. - -Release-mode `/usr/bin/time -v` evidence after one warm-up per input was: - -| Input | Elapsed runs | Maximum RSS runs (KiB) | -| ----------------------- | ---------------------- | ---------------------- | -| 1 MiB | 0.01 s, 0.01 s, 0.01 s | 7,168; 7,232; 7,316 | -| 5 MiB | 0.06 s, 0.06 s, 0.06 s | 23,608; 23,624; 23,708 | -| 10 MiB | 0.12 s, 0.12 s, 0.12 s | 44,100; 44,380; 44,080 | -| 256 nested conditionals | 0.01 s, 0.01 s, 0.01 s | 8,808; 8,672; 8,852 | - -The large inputs were exact-size single rules generated from a fixed `all:` -header, repeated `a` bytes, and a newline. A `stat` assertion checked every -length before timing. The nested input contained 256 deterministic `ifdef`/ -`endif` pairs around one rule. Growth was sub-linear across the measured sizes, -the 10 MiB median was 0.12 seconds, and all resident-set measurements were -below 256 MiB. - -An external Python subprocess invoked the release binary against a -representative Makefile, decoded JSON with the standard library, asserted -schema version 1 and complete status, and found `build`, `lint`, and `test`. -The following derived consumer summary is not the schema-v1 document: +Repository-verifiable evidence consists of: -```plaintext -{"schema_version":1,"status":"complete","required_targets":["build","lint","test"],"language_binding":false} -``` +- schema contract tests that validate complete and recovered reports and + deserialize a consumer-shaped view without makeutil domain types; +- corpus and snapshot tests that retain facts alongside positioned and + unpositioned diagnostics for recovered parses; +- behavioural and end-to-end tests for paths, standard input, streams, exit + codes, deterministic JSON, fatal read failures, and hostile inert source; +- source-adapter tests that enforce the inclusive 16 MiB bound and logical-path + error details; and +- fixture and contract tests that report include facts without opening include + targets. + +The repeatable performance guardrail remains the deterministic large-input and +deep-conditional procedure in `Concrete steps`; its thresholds are acceptance +requirements rather than a record of a particular host run. ## Interfaces and dependencies @@ -1051,20 +939,13 @@ Decision log. ## Revision note -Initially revised 2026-07-13 after independent architecture and automated -review to freeze path, range, schema, parser-port, failure-output, CLI merge, -security, performance, and dependency decisions and to import and correct the -OrthoConfig 0.8.0 guide. Implementation completed on 2026-07-13 with -deterministic gates, manual acceptance, performance measurements, and external -consumer and include-boundary evidence recorded above. Pull request review -remains pending. Revised again on 2026-07-14 to inject the ambient filesystem -capability at the CLI boundary while preserving the stable source error and -process diagnostic contracts. Terminal documentation review then clarified -hashing ownership and replaced planning-time scaffold descriptions in the -current repository orientation and applied the valid CLI and parser-helper -fixes. Independent validation passed all post-correction gates; exact -terminal-diff automated certification remains pending in the pull request. The -shared source-reader test double was subsequently moved to a test-only common -module because Cargo does not export `cfg(test)` automatic mocks to -integration-test crates. Independent post-change repository gates passed with -warnings denied across every integration-test binary. +Initially revised 2026-07-13 to freeze path, range, schema, parser-port, +failure-output, CLI merge, security, performance, and dependency decisions and +to import and correct the OrthoConfig 0.8.0 guide. Implementation completed +with checked-in schema, parser, CLI, security, recovery, and bounded-input +tests. A later revision injected the ambient filesystem capability at the CLI +boundary while preserving stable source errors and process diagnostics, +clarified hashing ownership, and moved the shared source-reader test double to +a test-only common module because Cargo does not export `cfg(test)` automatic +mocks to integration-test crates. Acceptance now depends only on repeatable +repository gates and checked-in contract evidence. diff --git a/tests/corpus.rs b/tests/corpus.rs index ea35eaf..958dbf0 100644 --- a/tests/corpus.rs +++ b/tests/corpus.rs @@ -1,11 +1,10 @@ -//! Real-estate corpus regressions. +//! Unsupported-syntax corpus regressions. //! -//! Each fixture here reduces a construct observed in an external repository -//! during a prior compatibility audit. The tests pin the parser's -//! honest behaviour for constructs it cannot yet represent: the parse must -//! degrade to `recovered` with a positioned diagnostic, never report a -//! false `complete`. If an upstream `makefile-lossless` release learns one -//! of these constructs, the corresponding test fails on purpose so the pin +//! Each fixture is a reduced unsupported-syntax regression fixture. The tests +//! pin the parser's honest behaviour for constructs it cannot yet represent: +//! the parse must degrade to `recovered` with a positioned diagnostic, never +//! report a false `complete`. If an upstream `makefile-lossless` release learns +//! one of these constructs, the corresponding test fails on purpose so the pin //! and the expectations are revisited together. use makeutil::{adapters::MakefileLosslessParser, domain::ParseStatus, parse_source}; From a9a246564421122bd15b9b4bf9c2fce6ea1ccd3f Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Jul 2026 22:08:31 +0200 Subject: [PATCH 24/29] Strengthen review validation contracts Preserve fatal `git grep` failures in the provenance gate. Replace imported guide placeholders with canonical references and document the public typos configuration interfaces. Pin diagnostic ordering and source spans across both upstream error channels. Retain positioned recovery evidence and verify the parser patch source in `Cargo.lock` from the manifest configuration. --- Makefile | 13 +++++--- docs/developers-guide.md | 3 +- docs/rstest-bdd-users-guide.md | 16 +++++----- scripts/generate_typos_config.py | 53 ++++++++++++++++++++++++++++++-- scripts/typos_rollout.py | 53 ++++++++++++++++++++++++++++++-- src/adapters/makefile_tests.rs | 45 +++++++++++++++++++++++++-- tests/corpus.rs | 6 ++++ tests/domain_contract.rs | 38 +++++++++++++++++++++-- 8 files changed, 203 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 851ea11..07c8a55 100644 --- a/Makefile +++ b/Makefile @@ -76,11 +76,16 @@ spelling: ## Enforce en-GB-oxendict spelling in Markdown prose xargs -0 $(TYPOS) --config typos.toml --force-exclude provenance: ## Reject non-reproducible operational provenance - ! git grep -n -i -E \ + @status=0; \ + git grep -n -i -E \ 'Concordat|/data/|pg-embed|Parabellum|compatibility audit|external consumer environment|subprocess trial|independent validation' \ - -- ':(exclude)Makefile' - ! git grep -n -i 'leynos/' -- '*.md' '*.rs' '*.py' '*.toml' \ - ':(exclude)Cargo.toml' + -- ':(exclude)Makefile' || status=$$?; \ + case $$status in 0) exit 1 ;; 1) ;; *) exit $$status ;; esac + @status=0; \ + git grep -n -i 'leynos/' -- '*.md' '*.rs' '*.py' '*.toml' \ + ':(exclude)Cargo.toml' \ + ':(exclude)docs/rstest-bdd-users-guide.md' || status=$$?; \ + case $$status in 0) exit 1 ;; 1) ;; *) exit $$status ;; esac nixie: ## Validate Mermaid diagrams $(NIXIE) --no-sandbox diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f01397b..5c19a5a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -142,7 +142,8 @@ maintained prose. `make provenance` rejects personal repository references, local paths, named operational projects, and claims of validation that cannot be reproduced from the repository. `make markdownlint` includes this check. Generic consumer -contracts and technical dependency coordinates remain permitted. +contracts, technical dependency coordinates, and canonical citations in the +imported `rstest-bdd` guide remain permitted. ### Security audit ignores diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index cae0596..96fb4db 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1063,7 +1063,7 @@ must state whether protected debug metadata was enabled. > share mutable GPUI state across BDD steps in `rstest-bdd` 0.6.0, but it > exists to work around the current `StepContext::borrow_mut` contract > selected by -> [ADR-007](https://github.com/owner/rstest-bdd/blob/main/docs/adr-007-harness-context-injection.md). +> [ADR-007](https://github.com/leynos/rstest-bdd/blob/main/docs/adr-007-harness-context-injection.md). > Sections > 2.7.6.2 and 2.7.6.5 of the design document > ([rstest-bdd design][rstest-bdd-design]) and roadmap items 12.1.x track @@ -2781,11 +2781,11 @@ integrate acceptance criteria into their Rust test suites and to engage all three amigos in the specification process. [scenario-status]: https://docs.rs/rstest-bdd/latest/rstest_bdd/reporting/enum.ScenarioStatus.html -[adr-001]: https://github.com/owner/rstest-bdd/blob/main/docs/adr-001-async-fixtures-and-test.md -[adr-013]: https://github.com/owner/rstest-bdd/blob/main/docs/adr-013-adopt-whitaker-no-unwrap-or-else-panic.md -[gherkin-syntax]: https://github.com/owner/rstest-bdd/blob/main/docs/gherkin-syntax.md#section-12-the-anatomy-of-a-feature-file -[migration-async-patterns]: https://github.com/owner/rstest-bdd/blob/main/docs/cucumber-rs-migration-and-async-patterns.md -[rstest-bdd-design]: https://github.com/owner/rstest-bdd/blob/main/docs/rstest-bdd-design.md -[design-runner-parallelism]: https://github.com/owner/rstest-bdd/blob/main/docs/rstest-bdd-design.md#2767-test-runner-parallelism-and-scenario-state -[developer-serial-nextest]: https://github.com/owner/rstest-bdd/blob/main/docs/developers-guide.md#serial-file_serial-and-nextest-test-groups +[adr-001]: https://github.com/leynos/rstest-bdd/blob/main/docs/adr-001-async-fixtures-and-test.md +[adr-013]: https://github.com/leynos/rstest-bdd/blob/main/docs/adr-013-adopt-whitaker-no-unwrap-or-else-panic.md +[gherkin-syntax]: https://github.com/leynos/rstest-bdd/blob/main/docs/gherkin-syntax.md#section-12-the-anatomy-of-a-feature-file +[migration-async-patterns]: https://github.com/leynos/rstest-bdd/blob/main/docs/cucumber-rs-migration-and-async-patterns.md +[rstest-bdd-design]: https://github.com/leynos/rstest-bdd/blob/main/docs/rstest-bdd-design.md +[design-runner-parallelism]: https://github.com/leynos/rstest-bdd/blob/main/docs/rstest-bdd-design.md#2767-test-runner-parallelism-and-scenario-state +[developer-serial-nextest]: https://github.com/leynos/rstest-bdd/blob/main/docs/developers-guide.md#serial-file_serial-and-nextest-test-groups [nextest-test-groups]: https://nexte.st/docs/configuration/test-groups/ diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index ce674ed..fde392e 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -19,7 +19,27 @@ def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Dictionary: - """Load the cached project base merged with local repository policy.""" + """Load the cached project base merged with local repository policy. + + Parameters + ---------- + repository + Repository containing the cached base and optional local overlay. + + Returns + ------- + rollout.Dictionary + The validated base dictionary merged with local policy. + + Raises + ------ + OSError + If a dictionary file cannot be read. + TypeError + If a dictionary contains values of the wrong type. + ValueError + If a dictionary is invalid or overlay corrections conflict. + """ dictionary = rollout.load_dictionary(repository / ".typos-oxendict-base.toml") local_overlay = repository / "typos.local.toml" if local_overlay.exists(): @@ -42,7 +62,36 @@ def main( source: str | Path = DEFAULT_BASE_SOURCE, offline: bool = False, ) -> rollout.RefreshResult: - """Refresh the project base cache and write the merged configuration.""" + """Refresh the base cache and write the merged repository configuration. + + Parameters + ---------- + output + Destination configuration, or ``repository / "typos.toml"`` when + omitted. + repository + Repository containing cache metadata and local dictionary policy. + source + Local path or URL for the authoritative base dictionary. + offline + Use the existing valid cache without contacting or reading ``source``. + + Returns + ------- + rollout.RefreshResult + Cache status and the cache path used to render the configuration. + + Raises + ------ + FileNotFoundError + If offline mode is requested without a valid cached dictionary. + OSError + If source, cache, metadata, overlay, or output access fails. + TypeError + If dictionary data contains values of the wrong type. + ValueError + If dictionary data is invalid or overlay corrections conflict. + """ result = rollout.refresh_base( source, repository / ".typos-oxendict-base.toml", diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index 04c4a94..189ea23 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -44,7 +44,16 @@ class Dictionary: @dataclasses.dataclass(frozen=True) class RefreshResult: - """Describe whether the untracked base-dictionary cache changed.""" + """Describe the result of refreshing the base-dictionary cache. + + Attributes + ---------- + status + Refresh outcome: ``refreshed``, ``current``, ``stale-cache``, or + ``offline-cache``. + cache + Path to the validated cache used for configuration generation. + """ status: str cache: pathlib.Path @@ -104,12 +113,50 @@ def _dictionary_from_text(text: str) -> Dictionary: def load_dictionary(path: pathlib.Path) -> Dictionary: - """Load a validated base dictionary from *path*.""" + """Load and validate a dictionary from a TOML file. + + Parameters + ---------- + path + Dictionary file to load. + + Returns + ------- + Dictionary + Validated, deterministically ordered dictionary entries. + + Raises + ------ + OSError + If ``path`` cannot be read. + TypeError + If a dictionary value has the wrong type. + ValueError + If the TOML or dictionary schema is invalid. + """ return _dictionary_from_text(path.read_text(encoding="utf-8")) def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: - """Merge a base dictionary with a non-conflicting local overlay.""" + """Merge a base dictionary with a non-conflicting local overlay. + + Parameters + ---------- + base + Project-owned base dictionary. + local + Repository overlay whose entries extend ``base``. + + Returns + ------- + Dictionary + Deterministically ordered union of both dictionaries. + + Raises + ------ + ValueError + If both dictionaries correct the same word differently. + """ corrections = dict(base.corrections) for word, correction in local.corrections: existing = corrections.get(word) diff --git a/src/adapters/makefile_tests.rs b/src/adapters/makefile_tests.rs index 672b6aa..1cfa1fb 100644 --- a/src/adapters/makefile_tests.rs +++ b/src/adapters/makefile_tests.rs @@ -12,7 +12,7 @@ use super::{ ensure_round_trip, }; use crate::{ - domain::AssignmentOperator, + domain::{AssignmentOperator, SourceSpan}, ports::{MakefileParser as _, ParserPortError, SyntaxObservation}, }; @@ -100,8 +100,47 @@ fn all_upstream_diagnostic_channels_are_retained_for_large_sources() { collect_diagnostics(&parsed, &source, &mut observations) .expect("valid upstream diagnostic spans should be retained"); + let positioned_count = parsed.positioned_errors().len(); + assert_eq!(observations.len(), positioned_count + parsed.errors().len()); assert_eq!( - observations.len(), - parsed.positioned_errors().len() + parsed.errors().len() + observations.first(), + Some(&SyntaxObservation::Diagnostic { + message: "expected ':'".to_owned(), + code: None, + span: SourceSpan { + start: 106_470, + end: 106_476, + }, + }) + ); + assert_eq!( + observations.get(1), + Some(&SyntaxObservation::Diagnostic { + message: "expected ':'".to_owned(), + code: None, + span: SourceSpan { + start: 106_444, + end: 106_450, + }, + }) + ); + assert_eq!( + observations.get(positioned_count.saturating_sub(1)), + Some(&SyntaxObservation::Diagnostic { + message: "expected ':'".to_owned(), + code: None, + span: SourceSpan { start: 0, end: 6 }, + }) + ); + assert_eq!( + observations.get(positioned_count), + Some(&SyntaxObservation::Diagnostic { + message: "expected ':'".to_owned(), + code: None, + span: SourceSpan { + start: source.len(), + end: source.len(), + }, + }) ); } diff --git a/tests/corpus.rs b/tests/corpus.rs index 958dbf0..61573a2 100644 --- a/tests/corpus.rs +++ b/tests/corpus.rs @@ -28,6 +28,12 @@ fn bare_error_directive_recovers_with_facts_retained() { !report.parse.diagnostics.is_empty(), "a recovered parse must carry at least one diagnostic", ); + assert!( + report.parse.diagnostics.iter().any(|diagnostic| { + diagnostic.location.start_byte == 330 && diagnostic.location.end_byte == 331 + }), + "the recovered parse must retain the positioned upstream diagnostic", + ); let variable_names: Vec<&str> = report .variables diff --git a/tests/domain_contract.rs b/tests/domain_contract.rs index 1746c09..9583212 100644 --- a/tests/domain_contract.rs +++ b/tests/domain_contract.rs @@ -161,14 +161,46 @@ fn assignment_operators_match_schema_values( #[rstest] fn parser_version_matches_manifest_pin_and_schema_constant() { let parser_version = ToolIdentity::default().parser_version; + let manifest = include_str!("../Cargo.toml"); let expected_manifest_entry = format!(r#"makefile-lossless = "={parser_version}""#); assert!( - include_str!("../Cargo.toml") - .lines() - .any(|line| line == expected_manifest_entry), + manifest.lines().any(|line| line == expected_manifest_entry), "Cargo.toml must pin makefile-lossless to {parser_version}" ); + let patch_entry = manifest + .lines() + .find(|line| line.starts_with("makefile-lossless = { git = ")) + .expect("Cargo.toml should configure the established parser patch"); + let patch_repository = patch_entry + .split_once("git = \"") + .and_then(|(_, value)| value.split_once('"')) + .map(|(value, _)| value) + .expect("parser patch should declare a git repository"); + let patch_revision = patch_entry + .split_once("rev = \"") + .and_then(|(_, value)| value.split_once('"')) + .map(|(value, _)| value) + .expect("parser patch should declare a revision"); + let expected_lock_version = format!("version = \"{parser_version}\""); + let lock_package = include_str!("../Cargo.lock") + .split("[[package]]") + .find(|package| { + package + .lines() + .any(|line| line == "name = \"makefile-lossless\"") + && package.lines().any(|line| line == expected_lock_version) + }) + .expect("Cargo.lock should resolve the pinned parser package"); + let expected_lock_source = + format!("source = \"git+{patch_repository}?rev={patch_revision}#{patch_revision}\""); + assert!( + lock_package + .lines() + .any(|line| line == expected_lock_source), + "Cargo.lock must resolve the parser patch repository and revision" + ); + let schema: serde_json::Value = serde_json::from_str(include_str!("../schemas/makeutil.parse.v1.schema.json")) .expect("schema should be valid JSON"); From 2be5ac032e692584671eb73454c1ad50b595051e Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 14:34:56 +0200 Subject: [PATCH 25/29] Bound fallback diagnostic storage Resolve only source lines referenced by fallback diagnostics so newline-heavy input cannot allocate one span per physical line. Preserve diagnostic order, span calculation, and end-of-source fallback behaviour. Expose reproducible Makefile validation through make, correct the imported upstream links and repository layout, and restore single-line table rows in the vendored guide. --- Makefile | 6 ++- docs/developers-guide.md | 2 +- .../adr-0001-single-file-gnu-make-parse.md | 6 +-- docs/ortho-config-users-guide.md | 8 ++-- docs/repository-layout.md | 7 ++++ docs/rstest-bdd-users-guide.md | 17 +++----- src/adapters/makefile.rs | 40 +++++++++++-------- 7 files changed, 49 insertions(+), 37 deletions(-) diff --git a/Makefile b/Makefile index 07c8a55..5bf127a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint spelling provenance nixie audit rust-audit +.PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint spelling provenance validate-makefile nixie audit rust-audit SHELL := bash @@ -84,9 +84,13 @@ provenance: ## Reject non-reproducible operational provenance @status=0; \ git grep -n -i 'leynos/' -- '*.md' '*.rs' '*.py' '*.toml' \ ':(exclude)Cargo.toml' \ + ':(exclude)docs/ortho-config-users-guide.md' \ ':(exclude)docs/rstest-bdd-users-guide.md' || status=$$?; \ case $$status in 0) exit 1 ;; 1) ;; *) exit $$status ;; esac +validate-makefile: ## Validate the Makefile + mbake validate Makefile + nixie: ## Validate Mermaid diagrams $(NIXIE) --no-sandbox diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5c19a5a..ea06831 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -143,7 +143,7 @@ maintained prose. operational projects, and claims of validation that cannot be reproduced from the repository. `make markdownlint` includes this check. Generic consumer contracts, technical dependency coordinates, and canonical citations in the -imported `rstest-bdd` guide remain permitted. +imported upstream guides remain permitted. ### Security audit ignores diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 49455e0..24fdfaa 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -718,7 +718,7 @@ checked-in tests support the ADR's Accepted status without relying on evidence from another repository or runtime environment. Run `make fmt` after documentation changes, followed by `make markdownlint` and -`make nixie`. If the Makefile changes, also run `mbake validate Makefile`. Then +`make nixie`. If the Makefile changes, also run `make validate-makefile`. Then run the four required gates, update this plan and its retrospective, and commit. Do not mark the plan COMPLETE until every acceptance criterion has current repository evidence. @@ -778,7 +778,7 @@ make markdownlint make nixie ``` -If the milestone changes `Makefile`, also run `mbake validate Makefile`. +If the milestone changes `Makefile`, also run `make validate-makefile`. The documented command contract can be reproduced with: @@ -829,7 +829,7 @@ Acceptance requires all ADR criteria plus the following evidence: - `make check-fmt`, `make typecheck`, `make lint`, and `make test` pass at every milestone and at final acceptance. - `make markdownlint` and `make nixie` pass for documentation; - `mbake validate Makefile` passes if the Makefile changes. + `make validate-makefile` passes if the Makefile changes. - A consumer-shaped JSON contract test passes without linking implementation types. - `make provenance` confirms that maintained text contains no prohibited diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 257b1b4..741d5f3 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -1,7 +1,7 @@ # OrthoConfig user's guide > **Upstream reference:** This imported guide describes the -> [OrthoConfig repository](https://github.com/owner/ortho-config), not the +> [OrthoConfig repository](https://github.com/leynos/ortho-config), not the > makeutil workspace. Repository-relative paths, `make` commands, examples, > tests, and assets mentioned below—including Hello World and > `config/overrides.toml`—belong to that upstream repository. @@ -55,7 +55,7 @@ values from multiple sources. The core features are: The upstream OrthoConfig workspace bundles an executable Hello World example under `examples/hello_world`. It layers defaults, environment variables, and CLI flags via the derive macro; see its -[README](https://github.com/owner/ortho-config/blob/main/examples/hello_world/README.md) +[README](https://github.com/leynos/ortho-config/blob/main/examples/hello_world/README.md) for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven Development) scenarios that validate behaviour end-to-end. @@ -924,7 +924,7 @@ fn main() -> Result<(), Box> { ### Hello world walkthrough - + The `hello_world` example crate demonstrates these patterns in a compact setting. Global options such as `--recipient` or `--salutation` are resolved by @@ -1025,7 +1025,7 @@ action to perform. An enum of subcommands is annotated with `#[clap_dispatch(fn run(...))]`, and the `load_and_merge_subcommand_for` function can be called on each variant before dispatching. See the `Subcommand Configuration` section of the `OrthoConfig` -[README](https://github.com/owner/ortho-config/blob/main/README.md) for a +[README](https://github.com/leynos/ortho-config/blob/main/README.md) for a complete example. ## Error handling diff --git a/docs/repository-layout.md b/docs/repository-layout.md index afa12e8..9d91160 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -39,6 +39,9 @@ compact and omits build output such as `target/`. │ └── ... ├── schemas/ │ └── makeutil.parse.v1.schema.json +├── scripts/ +│ ├── generate_typos_config.py +│ └── typos_rollout.py ├── src/ │ ├── adapters/ │ ├── domain/ @@ -102,6 +105,10 @@ compact and omits build output such as `target/`. constraints, and success criteria that govern the design. - `schemas/`: Holds normative, versioned external JSON contracts. +- `scripts/generate_typos_config.py`: Generates the repository spelling + configuration from the project-owned Oxford English dictionary. +- `scripts/typos_rollout.py`: Refreshes and checks the spelling configuration + used by the repository-wide spelling workflow. - `src/adapters/`: Implements CLI, source, and GNU Make parser edges. - `src/domain/`: Owns the stable report and source-location model. - `src/application.rs`: Validates source and assembles reports through the diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 96fb4db..f0d104f 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -1085,19 +1085,12 @@ must state whether protected debug metadata was enabled. > > -> | Operation | Vendored gpui (regression suite + these snippets) | Published -> `gpui 0.2.2` (downstream adopters) | +> | Operation | Vendored gpui (regression suite + these snippets) | Published `gpui 0.2.2` (downstream adopters) | > | --- | --- | --- | -> | `add_window_view` closure | `\|_context\| View::default()` (one argument) | -> `\|_window, view_cx\| View::new(view_cx)` (two arguments) | -> | obtain window handle | `visual_cx.window_handle()` (inherent method on -> `VisualTestContext`) | `vcx.window_handle()` (same call, but `window_handle` -> is a `VisualContext` trait method, so add `use gpui::VisualContext;`) | -> | `VisualTestContext::from_window` | returns `Option` -> (`let … else { panic!(…) }`) | returns `VisualTestContext` by value (no -> `Option`) | -> | `read_entity` / `update_entity` | `Option`/`Result` wrappers (`Some(1)`, -> `Ok(())`) | identity `type Result = T`; returns `R` directly | +> | `add_window_view` closure | `\|_context\| View::default()` (one argument) | `\|_window, view_cx\| View::new(view_cx)` (two arguments) | +> | obtain window handle | `visual_cx.window_handle()` (inherent method on `VisualTestContext`) | `vcx.window_handle()` (same call, but `window_handle` is a `VisualContext` trait method, so add `use gpui::VisualContext;`) | +> | `VisualTestContext::from_window` | returns `Option` (`let … else { panic!(…) }`) | returns `VisualTestContext` by value (no `Option`) | +> | `read_entity` / `update_entity` | `Option`/`Result` wrappers (`Some(1)`, `Ok(())`) | identity `type Result = T`; returns `R` directly | > > diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index 1104260..51ab48f 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -1,5 +1,7 @@ //! `makefile-lossless` 0.3.40 adapter for the domain-owned parser port. +use std::collections::BTreeMap; + use makefile_lossless::{ Conditional, Include, @@ -292,21 +294,26 @@ fn collect_diagnostics( span: span(error.range, source.len())?, }); } - let line_spans: Vec<_> = if parsed.errors().is_empty() { - Vec::new() - } else { - source - .split_inclusive('\n') - .scan(0_usize, |start, segment| { - let span = SourceSpan { - start: *start, - end: start.saturating_add(segment.trim_end_matches(['\r', '\n']).len()), - }; - *start = start.saturating_add(segment.len()); - Some(span) - }) - .collect() - }; + let mut line_spans: BTreeMap<_, Option> = parsed + .errors() + .iter() + .map(|error| (error.line.saturating_sub(1), None)) + .collect(); + let mut unresolved_lines = line_spans.len(); + let mut start = 0_usize; + for (line, segment) in source.split_inclusive('\n').enumerate() { + if let Some(resolved_span) = line_spans.get_mut(&line) { + *resolved_span = Some(SourceSpan { + start, + end: start.saturating_add(segment.trim_end_matches(['\r', '\n']).len()), + }); + unresolved_lines = unresolved_lines.saturating_sub(1); + } + start = start.saturating_add(segment.len()); + if unresolved_lines == 0 { + break; + } + } let end_of_source = SourceSpan { start: source.len(), end: source.len(), @@ -316,8 +323,9 @@ fn collect_diagnostics( message: error.message.clone(), code: None, span: line_spans - .get(error.line.saturating_sub(1)) + .get(&error.line.saturating_sub(1)) .copied() + .flatten() .unwrap_or(end_of_source), }); } From 0e51b3497c632f17c136eaef9435c6c63540d92c Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Jul 2026 17:37:49 +0200 Subject: [PATCH 26/29] Fix the ExecPlan CLI test target Name the checked-in `cli_e2e` integration target so the documented focused test command is directly runnable. --- docs/execplans/adr-0001-single-file-gnu-make-parse.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 24fdfaa..90ae188 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -746,7 +746,7 @@ test's actual module and scenario names and record exact output in `Progress`: RUSTFLAGS="-Zpolonius=next -D warnings" cargo test location_index --all-features RUSTFLAGS="-Zpolonius=next -D warnings" cargo test parser_adapter --all-features RUSTFLAGS="-Zpolonius=next -D warnings" cargo test --test parse_bdd --all-features -RUSTFLAGS="-Zpolonius=next -D warnings" cargo test --test parse_cli --all-features +RUSTFLAGS="-Zpolonius=next -D warnings" cargo test --test cli_e2e --all-features ``` The red run must fail because the new behaviour is absent, not because the test @@ -948,4 +948,5 @@ boundary while preserving stable source errors and process diagnostics, clarified hashing ownership, and moved the shared source-reader test double to a test-only common module because Cargo does not export `cfg(test)` automatic mocks to integration-test crates. Acceptance now depends only on repeatable -repository gates and checked-in contract evidence. +repository gates and checked-in contract evidence. The focused CLI command now +names the checked-in `cli_e2e` integration-test target. From 6e907eb4e10892ce1753e4dc96b8b52a5b5aad45 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 19:50:01 +0200 Subject: [PATCH 27/29] Harden parse boundaries and traversal Escape control characters in fatal diagnostics so caller-controlled paths cannot forge stderr lines. Expand black-box coverage for inert Make constructs, include non-traversal, exact path identity, and configuration isolation. Replace recursive conditional collection with an iterative event stack and prove the 256-level resource guardrail. Add the 0.2.0 migration guide and document the Makefile validation tooling. --- docs/contents.md | 2 + docs/design.md | 16 +- docs/developers-guide.md | 22 ++- .../adr-0001-single-file-gnu-make-parse.md | 16 +- docs/users-guide.md | 9 +- docs/v0-2-0-migration-guide.md | 40 ++++ src/adapters/cli.rs | 17 +- src/adapters/makefile.rs | 175 ++++++++---------- src/adapters/makefile_tests.rs | 68 ++++++- tests/cli_e2e.rs | 127 ++++++++++++- 10 files changed, 373 insertions(+), 119 deletions(-) create mode 100644 docs/v0-2-0-migration-guide.md diff --git a/docs/contents.md b/docs/contents.md index c48eb92..f0f64d1 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -11,6 +11,8 @@ set. implementation tooling for contributors. - [Repository layout](repository-layout.md) explains the generated project's top-level files, directories, and ownership boundaries. +- [Version 0.2.0 migration guide](v0-2-0-migration-guide.md) explains how to + replace the removed greeting API with the parse command and JSON contract. - [Polonius migration](polonius.md) records the compiler requirement, borrow-centric design rules, and audit inventory. - [Documentation style guide](documentation-style-guide.md) defines the diff --git a/docs/design.md b/docs/design.md index c791a02..3d23610 100644 --- a/docs/design.md +++ b/docs/design.md @@ -395,8 +395,8 @@ when deterministic failure testing requires them; they are not domain ports. 4. Parse with the GNU Make default of `makefile-lossless`. 5. Obtain the tree even when parser diagnostics exist. 6. Walk root items in source order. -7. Recurse into the `if` and `else` arms of each conditional while extending the - condition context. +7. Traverse the `if` and `else` arms with an explicit event stack while + extending one mutable condition context. 8. Emit flattened rule, variable, and include facts with global source-order ordinals. 9. Serialize one compact JSON document. @@ -459,11 +459,13 @@ Operation identifiers distinguish `cli`, `source-open`, `source-read`, `source-too-large`, `source-utf8`, `parse-internal`, `json-serialize`, and `stdout-write`. Normal success and recovered parsing emit no stderr. The detail includes the logical path for `source-open`, `source-read`, and -`source-too-large` failures. Backtraces and cause chains are not printed by -default. The binary may install one tracing subscriber, but it must never write -tracing events to stdout; the library installs no subscriber. Source contents -and unbounded raw paths are not tracing fields. This one-shot CLI emits no -metrics in the first slice. +`source-too-large` failures. Control characters in caller-supplied paths are +escaped before stderr formatting so each diagnostic remains one physical line; +the JSON report retains the exact caller-supplied logical path. Backtraces and +cause chains are not printed by default. The binary may install one tracing +subscriber, but it must never write tracing events to stdout; the library +installs no subscriber. Source contents and unbounded raw paths are not tracing +fields. This one-shot CLI emits no metrics in the first slice. ## 11. Verification strategy diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ea06831..5911329 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -73,12 +73,22 @@ are not domain ports or general utilities; reuse outside `collect_items` requires a new adapter-owned call-site with the same complete-observation contract, not a move into the domain or ports modules. +`TraversalEvent` and `schedule_conditional` are private makefile-adapter +mechanics for the iterative CST walk. They may be used only by `collect_items` +to preserve source order while mutating one conditional-ancestry vector. Do not +expose them through the parser port or reuse them as a general tree walker. + The CLI adapter's private extraction, report-production, and report-emission helpers divide its orchestration into focused steps. They may be called only by the CLI adapter and must remain ordinary private functions. Promote one to a port only if a distinct external capability needs the same contract, not merely to share implementation detail or simplify a test. +The private `escape_control_characters` helper is restricted to fatal stderr +details. It preserves printable Unicode and escapes controls so one diagnostic +cannot forge another physical line; it is not a general path normalizer or JSON +encoder. + The exact 0.3.40 parser requirement is temporarily patched to immutable fork commit `8dd35801b75b332c2ac2f995ae398ef8238559fa`, which adds `!=` lexer support. Keep the commit pin reproducible. When upgrading to an upstream @@ -105,7 +115,9 @@ Use `make all` as the public entrypoint for formatting, linting, and tests. `cargo nextest run` and falls back to `cargo test` when cargo-nextest is not available. `make audit` derives the Rust workspace root with `cargo metadata`, logs workspace member manifests, and runs `cargo audit` once from the workspace -root. `make coverage` uses `cargo llvm-cov` with `lld`. +root. `make coverage` uses `cargo llvm-cov` with `lld`. Run +`make validate-makefile` whenever `Makefile` changes; the target invokes +`mbake validate Makefile` as the repository's Makefile validation entrypoint. GitHub Actions Act validation lives in `.github/workflows/act-validation.yml`. The main `.github/workflows/ci.yml` workflow deliberately does not run @@ -127,8 +139,12 @@ not an unpinned `cargo +nightly`, because the development profile also requires the pinned Cranelift component. See [Polonius migration](polonius.md) before introducing borrow-checker workarounds. -Install `clang`, `lld`, `mold`, `python3`, and `cargo-audit` before running the -full generated workflow locally on Linux. +Install `clang`, `lld`, `mold`, `python3`, `cargo-audit`, and `mbake` before +running the full generated workflow locally on Linux. Install `mbake` with: + +```shell +uv tool install mbake +``` ## Spelling policy diff --git a/docs/execplans/adr-0001-single-file-gnu-make-parse.md b/docs/execplans/adr-0001-single-file-gnu-make-parse.md index 90ae188..75db735 100644 --- a/docs/execplans/adr-0001-single-file-gnu-make-parse.md +++ b/docs/execplans/adr-0001-single-file-gnu-make-parse.md @@ -209,6 +209,9 @@ stop and resolve the conflict before editing `Cargo.toml`. representation through the concrete parser and domain contract suite. - [x] (2026-07-18) Added contract coverage proving that no-default feature resolution excludes `ortho_config/serde_json`. +- [x] (2026-07-31) Replaced recursive conditional collection with an explicit + traversal stack, added a 256-level ancestry regression, and measured three + warmed release runs below the 256 MiB resident-memory guardrail. ## Surprises & discoveries @@ -385,6 +388,10 @@ stop and resolve the conflict before editing `Cargo.toml`. stringly typed drift and order-sensitive defects without creating reusable ports for implementation details. Permitted call sites and reuse policy are recorded in `docs/developers-guide.md`. Date: 2026-07-14. +- Decision: traverse conditional syntax with adapter-private LIFO events and + one mutable ancestry vector, cloning ancestry only when a fact is emitted. + Rationale: this preserves source and branch order while removing recursive + stack growth and repeated cloning at every nesting level. Date: 2026-07-31. - Decision: represent schema-v1 assignment operators with the closed, domain-owned `AssignmentOperator` enum shared by the parser port and report model. The empty representation is reserved for a `define` block without an @@ -882,7 +889,10 @@ Repository-verifiable evidence consists of: The repeatable performance guardrail remains the deterministic large-input and deep-conditional procedure in `Concrete steps`; its thresholds are acceptance -requirements rather than a record of a particular host run. +requirements. On 2026-07-31, the warmed release-mode 256-level regression +passed three times. GNU `/usr/bin/time` was unavailable, so Linux `wait4(2)` +resource accounting measured 4,964 KiB, 4,976 KiB, and 4,976 KiB peak resident +memory, with elapsed times of 0.005975 s, 0.005172 s, and 0.005230 s. ## Interfaces and dependencies @@ -949,4 +959,6 @@ clarified hashing ownership, and moved the shared source-reader test double to a test-only common module because Cargo does not export `cfg(test)` automatic mocks to integration-test crates. Acceptance now depends only on repeatable repository gates and checked-in contract evidence. The focused CLI command now -names the checked-in `cli_e2e` integration-test target. +names the checked-in `cli_e2e` integration-test target. The 2026-07-31 revision +records iterative conditional traversal, its 256-level regression and measured +resource bound, and the strengthened inert-input and diagnostic contracts. diff --git a/docs/users-guide.md b/docs/users-guide.md index 6987232..ceb4091 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -3,6 +3,9 @@ This guide explains how to parse one GNU Makefile into source-faithful JSON facts with `makeutil`. +Integrations upgrading from the greeting scaffold should follow the +[version 0.2.0 migration guide](v0-2-0-migration-guide.md). + ## Parse a file Pass exactly one UTF-8 path to the `parse` subcommand: @@ -49,5 +52,7 @@ are one-based. _Table 1: `makeutil parse` exit codes._ Fatal failures write a stable `makeutil: OPERATION: DETAIL` diagnostic to -standard error and do not intentionally emit JSON. Recovered reports are -insufficient proof that a Makefile is compliant. +standard error and do not intentionally emit JSON. Control characters in +caller-supplied paths are escaped in this diagnostic so its first line cannot +be forged. The JSON report preserves the exact caller-supplied logical path. +Recovered reports are insufficient proof that a Makefile is compliant. diff --git a/docs/v0-2-0-migration-guide.md b/docs/v0-2-0-migration-guide.md new file mode 100644 index 0000000..3b9b23e --- /dev/null +++ b/docs/v0-2-0-migration-guide.md @@ -0,0 +1,40 @@ +# Migrate to makeutil 0.2.0 + +Version 0.2.0 replaces the generated greeting scaffold with a command that +parses one GNU Makefile into versioned JSON facts. + +## Remove the greeting API + +Remove imports and calls to `makeutil::greet`. Version 0.2.0 does not provide a +replacement library function. Integrations should invoke the `makeutil` +executable and consume its versioned JSON output instead. + +## Invoke the parser + +Replace greeting invocations with the path form when the Makefile is stored on +disk: + +```shell +makeutil parse Makefile +``` + +Use standard input only when the integration already owns the source bytes: + +```shell +makeutil parse --stdin-filename Makefile - < Makefile +``` + +The input path and `--stdin-filename` are command-line-only values. +Configuration files and environment variables cannot supply them. + +## Update JSON consumers + +Validate output against +[`schemas/makeutil.parse.v1.schema.json`](../schemas/makeutil.parse.v1.schema.json) +and require `schema_version` to equal `1`. Exit status `0` emits a complete +report. Exit status `1` emits a recovered report with diagnostics. Exit status +`2` denotes a fatal invocation, input, serialization, or output failure and +does not intentionally emit JSON. + +See the [user guide](users-guide.md) for the complete command, stream, and +source-location contracts. diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 9c544a0..164701f 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -242,7 +242,22 @@ fn read_input( } fn fatal(stderr: &mut dyn std::io::Write, operation: &str, detail: &str) -> ProcessOutcome { - let message = format!("makeutil: {operation}: {detail}\n"); + let safe_detail = escape_control_characters(detail); + let message = format!("makeutil: {operation}: {safe_detail}\n"); let _write_result = stderr.write_all(message.as_bytes()); ProcessOutcome { exit_code: 2 } } + +fn escape_control_characters(detail: &str) -> String { + detail.chars().fold( + String::with_capacity(detail.len()), + |mut escaped, character| { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + escaped + }, + ) +} diff --git a/src/adapters/makefile.rs b/src/adapters/makefile.rs index 51ab48f..744d1c8 100644 --- a/src/adapters/makefile.rs +++ b/src/adapters/makefile.rs @@ -37,7 +37,7 @@ impl MakefileParser for MakefileLosslessParser { ensure_round_trip(&tree, source)?; let mut observations = Vec::new(); - collect_items(tree.items(), &[], source.len(), &mut observations)?; + collect_items(tree.items(), source.len(), &mut observations)?; collect_diagnostics(&parsed, source, &mut observations)?; Ok(ParserOutcome { observations }) } @@ -53,30 +53,95 @@ fn ensure_round_trip(tree: &Makefile, source: &str) -> Result<(), ParserPortErro fn collect_items( items: impl Iterator, - conditions: &[ConditionObservation], source_length: usize, observations: &mut Vec, ) -> Result<(), ParserPortError> { - for item in items { - match item { - MakefileItem::Rule(rule) => { - observations.push(rule_observation(&rule, conditions, source_length)?); + let mut pending = items + .map(TraversalEvent::Item) + .collect::>(); + pending.reverse(); + let mut conditions = Vec::new(); + + while let Some(event) = pending.pop() { + match event { + TraversalEvent::Push(condition) => conditions.push(condition), + TraversalEvent::Restore(depth) => conditions.truncate(depth), + TraversalEvent::Item(MakefileItem::Rule(rule)) => { + observations.push(rule_observation(&rule, &conditions, source_length)?); } - MakefileItem::Variable(variable) => { - observations.push(variable_observation(&variable, conditions, source_length)?); + TraversalEvent::Item(MakefileItem::Variable(variable)) => { + observations.push(variable_observation(&variable, &conditions, source_length)?); } - MakefileItem::Include(include) => { - observations.push(include_observation(&include, conditions, source_length)?); + TraversalEvent::Item(MakefileItem::Include(include)) => { + observations.push(include_observation(&include, &conditions, source_length)?); } - MakefileItem::Conditional(conditional) => { - collect_conditional(&conditional, conditions, source_length, observations)?; + TraversalEvent::Item(MakefileItem::Conditional(conditional)) => { + schedule_conditional(&conditional, source_length, conditions.len(), &mut pending)?; } - MakefileItem::Vpath(_) => {} + TraversalEvent::Item(MakefileItem::Vpath(_)) => {} } } Ok(()) } +enum TraversalEvent { + Item(MakefileItem), + Push(ConditionObservation), + Restore(usize), +} + +fn schedule_conditional( + conditional: &Conditional, + source_length: usize, + outer_depth: usize, + pending: &mut Vec, +) -> Result<(), ParserPortError> { + let opening = conditional + .syntax() + .children() + .find(|node| node.kind() == SyntaxKind::CONDITIONAL_IF) + .ok_or(ParserPortError::MissingField { + field: "conditional-opening", + })?; + let raw_kind = conditional + .conditional_type() + .ok_or(ParserPortError::MissingField { + field: "conditional-kind", + })?; + let kind = condition_kind(&raw_kind)?; + let expression = conditional.condition().unwrap_or_default(); + let if_condition = ConditionObservation { + kind, + expression: expression.clone(), + branch: ConditionBranch::If, + span: span(opening.text_range(), source_length)?, + }; + let mut events = vec![TraversalEvent::Push(if_condition)]; + events.extend(conditional.if_items().map(TraversalEvent::Item)); + events.push(TraversalEvent::Restore(outer_depth)); + + if conditional.has_else() { + let else_node = conditional + .syntax() + .children() + .find(|node| node.kind() == SyntaxKind::CONDITIONAL_ELSE) + .ok_or(ParserPortError::MissingField { + field: "conditional-else", + })?; + events.push(TraversalEvent::Push(ConditionObservation { + kind, + expression, + branch: ConditionBranch::Else, + span: span(else_node.text_range(), source_length)?, + })); + events.extend(conditional.else_items().map(TraversalEvent::Item)); + events.push(TraversalEvent::Restore(outer_depth)); + } + + pending.extend(events.into_iter().rev()); + Ok(()) +} + fn rule_observation( rule: &Rule, conditions: &[ConditionObservation], @@ -165,90 +230,6 @@ fn include_observation( }) } -fn collect_conditional( - conditional: &Conditional, - outer: &[ConditionObservation], - source_length: usize, - observations: &mut Vec, -) -> Result<(), ParserPortError> { - let opening = conditional - .syntax() - .children() - .find(|node| node.kind() == SyntaxKind::CONDITIONAL_IF) - .ok_or(ParserPortError::MissingField { - field: "conditional-opening", - })?; - let raw_kind = conditional - .conditional_type() - .ok_or(ParserPortError::MissingField { - field: "conditional-kind", - })?; - let kind = condition_kind(&raw_kind)?; - let expression = conditional.condition().unwrap_or_default(); - let mut if_conditions = outer.to_vec(); - if_conditions.push(ConditionObservation { - kind, - expression: expression.clone(), - branch: ConditionBranch::If, - span: span(opening.text_range(), source_length)?, - }); - collect_items( - conditional.if_items(), - &if_conditions, - source_length, - observations, - )?; - - collect_else_branch( - conditional, - outer, - ElseBranch { - kind, - expression, - source_length, - }, - observations, - )?; - Ok(()) -} - -fn collect_else_branch( - conditional: &Conditional, - outer: &[ConditionObservation], - branch: ElseBranch, - observations: &mut Vec, -) -> Result<(), ParserPortError> { - if !conditional.has_else() { - return Ok(()); - } - let else_node = conditional - .syntax() - .children() - .find(|node| node.kind() == SyntaxKind::CONDITIONAL_ELSE) - .ok_or(ParserPortError::MissingField { - field: "conditional-else", - })?; - let mut else_conditions = outer.to_vec(); - else_conditions.push(ConditionObservation { - kind: branch.kind, - expression: branch.expression, - branch: ConditionBranch::Else, - span: span(else_node.text_range(), branch.source_length)?, - }); - collect_items( - conditional.else_items(), - &else_conditions, - branch.source_length, - observations, - ) -} - -struct ElseBranch { - kind: ConditionKind, - expression: String, - source_length: usize, -} - #[derive(Debug, Default)] struct RecipeModifiers { silent: bool, diff --git a/src/adapters/makefile_tests.rs b/src/adapters/makefile_tests.rs index 1cfa1fb..45150df 100644 --- a/src/adapters/makefile_tests.rs +++ b/src/adapters/makefile_tests.rs @@ -1,5 +1,7 @@ //! Adapter invariant tests for unsupported upstream syntax. +use std::fmt::Write as _; + use makefile_lossless::{Makefile, Parse}; use pretty_assertions::assert_eq; use rstest::rstest; @@ -12,8 +14,8 @@ use super::{ ensure_round_trip, }; use crate::{ - domain::{AssignmentOperator, SourceSpan}, - ports::{MakefileParser as _, ParserPortError, SyntaxObservation}, + domain::{AssignmentOperator, ConditionBranch, ConditionKind, SourceSpan}, + ports::{ConditionObservation, MakefileParser as _, ParserPortError, SyntaxObservation}, }; #[rstest] @@ -89,6 +91,68 @@ fn multiline_define_preserves_exact_body() { ); } +#[rstest] +fn deeply_nested_conditionals_use_iterative_ancestry() { + const DEPTH: usize = 256; + + let mut source = String::new(); + let mut opening_spans = Vec::with_capacity(DEPTH); + for depth in 0..DEPTH { + let start = source.len(); + writeln!(&mut source, "ifdef LEVEL_{depth}") + .expect("writing a generated Makefile to a String should succeed"); + opening_spans.push(SourceSpan { + start, + end: source.len(), + }); + } + source.push_str("VALUE = yes\n"); + source.push_str(&"endif\n".repeat(DEPTH)); + + let outcome = MakefileLosslessParser + .parse(&source) + .expect("256 nested conditionals should parse without recursive traversal"); + let conditions = outcome + .observations + .iter() + .find_map(|observation| { + if let SyntaxObservation::Variable { conditions, .. } = observation { + Some(conditions) + } else { + None + } + }) + .expect("the generated variable should be observed"); + let first_span = opening_spans + .first() + .copied() + .expect("the generated source should contain an opening directive"); + let last_span = opening_spans + .last() + .copied() + .expect("the generated source should contain an opening directive"); + + assert_eq!(conditions.len(), DEPTH); + assert_eq!( + conditions.first(), + Some(&ConditionObservation { + kind: ConditionKind::Ifdef, + expression: "LEVEL_0".to_owned(), + branch: ConditionBranch::If, + span: first_span, + }) + ); + assert_eq!( + conditions.last(), + Some(&ConditionObservation { + kind: ConditionKind::Ifdef, + expression: "LEVEL_255".to_owned(), + branch: ConditionBranch::If, + span: last_span, + }) + ); +} + #[rstest] fn all_upstream_diagnostic_channels_are_retained_for_large_sources() { let source = "broken rule without colon\n".repeat(4_096); diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs index 05bf20e..6608b22 100644 --- a/tests/cli_e2e.rs +++ b/tests/cli_e2e.rs @@ -1,8 +1,16 @@ //! Black-box process contract tests for the public executable. +mod common; + use std::io::Write as _; use assert_cmd::Command; +use camino::Utf8Path; +use common::MockSourceReader; +use makeutil::adapters::{ + cli::{ProcessCapabilities, run_from, run_from_with_reader}, + source::MAX_SOURCE_BYTES, +}; use rstest::{fixture, rstest}; #[fixture] @@ -99,11 +107,21 @@ fn version_uses_clap_display_stream(mut makeutil_command: Command) { #[rstest] fn hostile_source_is_inert(mut makeutil_command: Command) { let temporary = tempfile::tempdir().expect("temporary directory should exist"); - let sentinel = temporary.path().join("sentinel"); + let shell_sentinel = temporary.path().join("shell-sentinel"); + let file_sentinel = temporary.path().join("file-sentinel"); + let assignment_sentinel = temporary.path().join("assignment-sentinel"); + let recipe_sentinel = temporary.path().join("recipe-sentinel"); let source = format!( - "X := $(shell touch {})\nall:\n\ttouch {}\n", - sentinel.display(), - sentinel.display() + concat!( + "SHELL := $(shell touch {})\n", + "FILE := $(file >{},created)\n", + "ASSIGNMENT != touch {}\n", + "all:\n\ttouch {}\n", + ), + shell_sentinel.display(), + file_sentinel.display(), + assignment_sentinel.display(), + recipe_sentinel.display(), ); let output = makeutil_command .args(["parse", "--stdin-filename", "Makefile", "-"]) @@ -111,7 +129,106 @@ fn hostile_source_is_inert(mut makeutil_command: Command) { .output() .expect("binary should run"); assert_eq!(output.status.code(), Some(0)); - assert!(!sentinel.exists()); + for sentinel in [ + shell_sentinel, + file_sentinel, + assignment_sentinel, + recipe_sentinel, + ] { + assert!( + !sentinel.exists(), + "{} should not exist", + sentinel.display() + ); + } +} + +#[rstest] +fn include_paths_are_not_opened_and_caller_path_spelling_is_preserved() { + let caller_path = "./fixtures/../caller.mk"; + let mut source_reader = MockSourceReader::new(); + source_reader + .expect_open() + .withf(move |path| path == Utf8Path::new(caller_path)) + .times(1) + .returning(|_| { + Ok(Box::new(std::io::Cursor::new( + b"include absent.mk\ninclude $(CONFIG_DIR)/dynamic.mk\nall:\n".as_slice(), + ))) + }); + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let capabilities = + ProcessCapabilities::new(&mut stdin, &mut stdout, &mut stderr, &source_reader); + + let outcome = run_from_with_reader(["makeutil", "parse", caller_path], capabilities); + + assert_eq!(outcome.exit_code, 0); + assert!(stderr.is_empty()); + let document: serde_json::Value = + serde_json::from_slice(&stdout).expect("stdout should be JSON"); + assert_eq!( + document + .pointer("/source/path") + .and_then(serde_json::Value::as_str), + Some(caller_path) + ); + assert_eq!( + document + .get("includes") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(2) + ); +} + +#[rstest] +fn environment_and_config_cannot_supply_parse_arguments(mut makeutil_command: Command) { + let temporary = tempfile::tempdir().expect("temporary directory should exist"); + let mut config = tempfile::NamedTempFile::new_in(temporary.path()) + .expect("configuration fixture should be created"); + config + .write_all(b"path = \"from-config.mk\"\nstdin_filename = \"config-logical.mk\"\n") + .expect("configuration fixture should be written"); + + let output = makeutil_command + .current_dir(temporary.path()) + .env("MAKEUTIL_PARSE_PATH", "from-environment.mk") + .env("MAKEUTIL_PARSE_STDIN_FILENAME", "environment-logical.mk") + .env("MAKEUTIL_PARSE_CONFIG_PATH", config.path()) + .arg("parse") + .output() + .expect("binary should run"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("")); +} + +#[rstest] +fn control_characters_cannot_inject_stderr_lines() { + let logical_path = "logical.mk\nforged\t\u{1b}\u{85}"; + let mut stdin = std::io::repeat(b'x'); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let outcome = run_from( + ["makeutil", "parse", "--stdin-filename", logical_path, "-"], + &mut stdin, + &mut stdout, + &mut stderr, + ); + + assert_eq!(outcome.exit_code, 2); + assert!(stdout.is_empty()); + assert_eq!( + String::from_utf8(stderr).expect("stderr should be UTF-8"), + format!( + "makeutil: source-too-large: source logical.mk\\nforged\\t\\u{{1b}}\\u{{85}} exceeds \ + the {MAX_SOURCE_BYTES}-byte limit\n" + ) + ); } #[rstest] From 8ec6b2b964a550b260dafce10dd6889733955088 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 12:09:16 +0200 Subject: [PATCH 28/29] Clarify diagnostic and migration prose Add the missing punctuation to the control-escaping contract and describe all migration exit statuses in one parallel sentence. --- docs/design.md | 2 +- docs/developers-guide.md | 2 +- docs/users-guide.md | 2 +- docs/v0-2-0-migration-guide.md | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/design.md b/docs/design.md index 3d23610..14a7a95 100644 --- a/docs/design.md +++ b/docs/design.md @@ -460,7 +460,7 @@ Operation identifiers distinguish `cli`, `source-open`, `source-read`, `stdout-write`. Normal success and recovered parsing emit no stderr. The detail includes the logical path for `source-open`, `source-read`, and `source-too-large` failures. Control characters in caller-supplied paths are -escaped before stderr formatting so each diagnostic remains one physical line; +escaped before stderr formatting, so each diagnostic remains one physical line; the JSON report retains the exact caller-supplied logical path. Backtraces and cause chains are not printed by default. The binary may install one tracing subscriber, but it must never write tracing events to stdout; the library diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5911329..fd0d2d2 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -85,7 +85,7 @@ port only if a distinct external capability needs the same contract, not merely to share implementation detail or simplify a test. The private `escape_control_characters` helper is restricted to fatal stderr -details. It preserves printable Unicode and escapes controls so one diagnostic +details. It preserves printable Unicode and escapes controls, so one diagnostic cannot forge another physical line; it is not a general path normalizer or JSON encoder. diff --git a/docs/users-guide.md b/docs/users-guide.md index ceb4091..848f12d 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -53,6 +53,6 @@ _Table 1: `makeutil parse` exit codes._ Fatal failures write a stable `makeutil: OPERATION: DETAIL` diagnostic to standard error and do not intentionally emit JSON. Control characters in -caller-supplied paths are escaped in this diagnostic so its first line cannot +caller-supplied paths are escaped in this diagnostic, so its first line cannot be forged. The JSON report preserves the exact caller-supplied logical path. Recovered reports are insufficient proof that a Makefile is compliant. diff --git a/docs/v0-2-0-migration-guide.md b/docs/v0-2-0-migration-guide.md index 3b9b23e..224b4a0 100644 --- a/docs/v0-2-0-migration-guide.md +++ b/docs/v0-2-0-migration-guide.md @@ -32,9 +32,9 @@ Configuration files and environment variables cannot supply them. Validate output against [`schemas/makeutil.parse.v1.schema.json`](../schemas/makeutil.parse.v1.schema.json) and require `schema_version` to equal `1`. Exit status `0` emits a complete -report. Exit status `1` emits a recovered report with diagnostics. Exit status -`2` denotes a fatal invocation, input, serialization, or output failure and -does not intentionally emit JSON. +report, status `1` emits a recovered report with diagnostics, and status `2` +denotes a fatal invocation, input, serialization, or output failure and does +not intentionally emit JSON. See the [user guide](users-guide.md) for the complete command, stream, and source-location contracts. From c7281e41ce8a28b0d68cbb0c80dfaf4a52c454fc Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 12:12:57 +0200 Subject: [PATCH 29/29] Rename the migration guide for 0.1.0 Align the guide filename, title, content, and documentation links with the first unpublished release version. --- docs/contents.md | 2 +- docs/users-guide.md | 2 +- ...{v0-2-0-migration-guide.md => v0-1-0-migration-guide.md} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename docs/{v0-2-0-migration-guide.md => v0-1-0-migration-guide.md} (88%) diff --git a/docs/contents.md b/docs/contents.md index f0f64d1..33316e0 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -11,7 +11,7 @@ set. implementation tooling for contributors. - [Repository layout](repository-layout.md) explains the generated project's top-level files, directories, and ownership boundaries. -- [Version 0.2.0 migration guide](v0-2-0-migration-guide.md) explains how to +- [Version 0.1.0 migration guide](v0-1-0-migration-guide.md) explains how to replace the removed greeting API with the parse command and JSON contract. - [Polonius migration](polonius.md) records the compiler requirement, borrow-centric design rules, and audit inventory. diff --git a/docs/users-guide.md b/docs/users-guide.md index 848f12d..ce9ccec 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -4,7 +4,7 @@ This guide explains how to parse one GNU Makefile into source-faithful JSON facts with `makeutil`. Integrations upgrading from the greeting scaffold should follow the -[version 0.2.0 migration guide](v0-2-0-migration-guide.md). +[version 0.1.0 migration guide](v0-1-0-migration-guide.md). ## Parse a file diff --git a/docs/v0-2-0-migration-guide.md b/docs/v0-1-0-migration-guide.md similarity index 88% rename from docs/v0-2-0-migration-guide.md rename to docs/v0-1-0-migration-guide.md index 224b4a0..2244830 100644 --- a/docs/v0-2-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -1,11 +1,11 @@ -# Migrate to makeutil 0.2.0 +# Migrate to makeutil 0.1.0 -Version 0.2.0 replaces the generated greeting scaffold with a command that +Version 0.1.0 replaces the generated greeting scaffold with a command that parses one GNU Makefile into versioned JSON facts. ## Remove the greeting API -Remove imports and calls to `makeutil::greet`. Version 0.2.0 does not provide a +Remove imports and calls to `makeutil::greet`. Version 0.1.0 does not provide a replacement library function. Integrations should invoke the `makeutil` executable and consume its versioned JSON output instead.