diff --git a/AGENTS.md b/AGENTS.md index b4ef5ec..0c48a57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,12 @@ request body or final report. - Avoid ceremonial documentation. Update shelves because the contract changed, not because a path changed. +## Durable Decision Discipline + +Follow the canonical [durable decision policy](docs/topics/documentation/README.md#durable-decision-discipline). +Important decisions are incomplete until the owning repository document is +current in the same change; chat, memory, and PR prose do not replace that owner. + ## RED/GREEN Testing Discipline Edict uses RED/GREEN test-driven development for nontrivial changes. The shared diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a6c6d7..5b513dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,9 +5,10 @@ current branch; it is not a future package plan. ## Workspace Shape -The Rust workspace has five members: +The Rust workspace has six members: ```text +flyingrobots-edict -> edict-syntax edict-cli -> edict-syntax xtask -> edict-syntax edict-provider-schema -> edict-syntax @@ -26,6 +27,20 @@ runtime without exposing Wasmtime types through Edict contracts. ## Crates +### `flyingrobots-edict` + +`flyingrobots-edict` exposes the Rust library name `edict`. It is the curated +public facade for source checking, stable diagnostic classifications, and +canonical Core, Target IR, and result-projection artifact identity operations. +The [public Rust API topic](docs/topics/public-rust-api/README.md) owns this +boundary. The facade deliberately does not re-export the implementation crate's +module tree. + +The package remains `publish = false`. Its presence defines and tests the +intended public Rust boundary; it neither authorizes nor claims crates.io +publication. The CLI remains the complete application-build and JSONL process +boundary. + ### `edict-syntax` `edict-syntax` is the implementation crate for more than syntax. Its public name diff --git a/CHANGELOG.md b/CHANGELOG.md index 3056115..74a121e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ versions still track specification maturity rather than a released product. ### Changed +- Reused release preparation's calendar validator in the policy structural + guard, rejecting impossible target dates in manually edited policy blocks. +- Completed the Rust facade value-model exports for Core, Target IR, result + projections, decoded canonical values, and diagnostic spans, allowing + consumers to construct artifact inputs and inspect operation results. +- Kept the Rust facade package version, exact implementation dependency, and + lockfile entry synchronized during release preparation, so the prepared + workspace resolves without repairing its lockfile. - Parsed release-policy fields as TOML values so comments and string contents cannot impersonate required scope/non-goal lists. Reconciliation and the structural guard now consume the same parsed string arrays. @@ -192,6 +200,13 @@ versions still track specification maturity rather than a released product. ### Added +- Added the non-publishing `flyingrobots-edict` package with the Rust library + name `edict`. The curated facade exposes source checking, stable diagnostic + classifications, and canonical Core, Target IR, and result-projection + artifact identity operations without making the implementation crate's + module tree part of the recommended public API. The package remains + `publish = false`; registry naming, publication, and release authorization + are explicitly outside this change. - Added explicit imported `Nominal` lawpack contracts. Nominal contracts preserve exact and ranged bounded-byte storage representations in Core while rejecting cross-assignment between distinct contract coordinates before diff --git a/Cargo.lock b/Cargo.lock index 17b9b80..b8a7702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,14 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" +[[package]] +name = "flyingrobots-edict" +version = "0.11.0-alpha.1" +dependencies = [ + "edict-syntax", + "serde_json", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index 250e364..ea7bb1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/edict", "crates/edict-cli", "crates/edict-provider-host-wasmtime", "crates/edict-provider-schema", diff --git a/README.md b/README.md index 94ccc40..0812ef9 100644 --- a/README.md +++ b/README.md @@ -517,11 +517,12 @@ input. The full stream contract and JSON Schemas are in the ### Using the library -`edict-syntax` is the front end. The one-call entry point parses and -surface-validates a source string: +The curated `edict` facade is the recommended Rust entry point. Its one-call +check parses and surface-validates a source string without exposing the +implementation crate's module tree: ```rust -use edict_syntax::{check, CheckOutcome}; +use edict::{check, CheckOutcome}; match check("package examples.hello@1;\n") { CheckOutcome::Valid => println!("ok"), @@ -530,8 +531,10 @@ match check("package examples.hello@1;\n") { } ``` -The underlying stages (`parse_module` then `validate_surface`) remain available -when you need the parsed module. +The implementation crate retains lower-level stages for repository-internal +consumers that need the parsed module. The curated facade remains +`publish = false`; the current release-engineering work does not authorize or +claim crates.io publication. --- diff --git a/crates/edict/Cargo.toml b/crates/edict/Cargo.toml new file mode 100644 index 0000000..525403f --- /dev/null +++ b/crates/edict/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "flyingrobots-edict" +version = "0.11.0-alpha.1" +description = "Curated public Rust facade for the Edict compiler and canonical artifacts" +readme = "README.md" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false +include = ["src/**", "tests/**", "README.md"] + +[lib] +name = "edict" + +[dependencies] +edict-syntax = { path = "../edict-syntax", version = "=0.11.0-alpha.1" } + +[dev-dependencies] +serde_json = "1" + +[lints] +workspace = true diff --git a/crates/edict/README.md b/crates/edict/README.md new file mode 100644 index 0000000..a1ecf50 --- /dev/null +++ b/crates/edict/README.md @@ -0,0 +1,24 @@ +# Edict for Rust + +`flyingrobots-edict` exposes the Rust library name `edict`. It is the curated +facade for Edict source checking, stable diagnostic classifications, and +canonical semantic-artifact identity operations. + +The complete application-build boundary remains the JSONL `edict` CLI. This +facade deliberately does not expose the implementation crate's full module +tree. + +```rust +use edict::{check, CheckOutcome}; + +assert_eq!( + check("package examples.public_facade@1;\n"), + CheckOutcome::Valid +); +``` + +## Publication status + +This package currently has `publish = false`. Its archive and dependency +closure are under release-engineering review. Nothing in this package grants +permission to publish it to crates.io. diff --git a/crates/edict/src/lib.rs b/crates/edict/src/lib.rs new file mode 100644 index 0000000..169d820 --- /dev/null +++ b/crates/edict/src/lib.rs @@ -0,0 +1,53 @@ +//! Curated public Rust facade for Edict. +//! +//! The [`check`] entry point is the supported one-call source check. Stable +//! failure classifications are grouped under [`diagnostic`], while canonical +//! semantic-artifact identity operations are grouped under [`artifact`]. The +//! implementation crate's broad module tree is intentionally not re-exported. +//! +//! ``` +//! use edict::{check, CheckOutcome}; +//! +//! assert_eq!( +//! check("package examples.public_facade@1;\n"), +//! CheckOutcome::Valid +//! ); +//! ``` +//! +//! Implementation modules are not part of this facade: +//! +//! ```compile_fail +//! use edict::parser::parse_module; +//! ``` + +pub use edict_syntax::{check, CheckOutcome}; + +/// Stable machine-usable failure classifications exposed by the public +/// facade. +pub mod diagnostic { + pub use edict_syntax::{ + CanonicalError, CanonicalErrorKind, CompilerError, CompilerErrorKind, CompilerStage, + ParseError, ParseErrorKind, ResultProjectionFailure, ResultProjectionFailureKind, + SemanticError, SemanticErrorKind, Span, TargetLoweringFailure, TargetLoweringFailureKind, + }; +} + +/// Canonical semantic-artifact values, encoders, and domain-framed identity +/// operations. Nested value models are exported so callers can construct +/// candidates and inspect results without importing implementation modules. +pub mod artifact { + pub use edict_syntax::{ + decode_canonical_cbor, decode_result_projection, digest_core_module, + digest_result_projection, digest_target_ir_artifact, encode_core_module, + encode_result_projection, encode_target_ir_artifact, verify_result_projection, + CanonicalError, CanonicalErrorKind, CanonicalValue, CompareOp, CoreBlock, CoreBound, + CoreBudget, CoreDigest, CoreExpr, CoreExternalActionBudget, CoreImport, CoreImportKind, + CoreIntent, CoreModule, CoreNode, CoreObstructionArm, CoreObstructionReason, CorePredicate, + CoreRequireFailureArm, CoreType, CoreValue, InputConstraint, InputConstraintSource, + LocalRef, ResourceRef, ResultProjection, ResultProjectionArtifact, ResultProjectionExpr, + ResultProjectionFailure, ResultProjectionFailureKind, ResultProjectionSource, + TargetIrArtifact, TargetIrExternalActionRequest, TargetIrIntent, TargetIrPureBinding, + TargetIrRequireFailure, TargetIrRequirement, TargetIrSemanticClosure, TargetIrStep, + VerifiedResultProjection, + }; +} diff --git a/crates/edict/tests/artifact_models.rs b/crates/edict/tests/artifact_models.rs new file mode 100644 index 0000000..b1d4ce2 --- /dev/null +++ b/crates/edict/tests/artifact_models.rs @@ -0,0 +1,146 @@ +//! Separate consumer crate: every Edict name comes from the curated facade. +use std::collections::BTreeMap; + +use edict::{ + artifact::{ + decode_canonical_cbor, decode_result_projection, digest_core_module, + digest_result_projection, digest_target_ir_artifact, encode_core_module, + encode_result_projection, encode_target_ir_artifact, verify_result_projection, + CanonicalValue, CoreBlock, CoreBudget, CoreExpr, CoreIntent, CoreModule, CoreType, + LocalRef, ResourceRef, ResultProjection, ResultProjectionExpr, ResultProjectionSource, + TargetIrArtifact, TargetIrIntent, TargetIrSemanticClosure, VerifiedResultProjection, + }, + check, + diagnostic::Span, + CheckOutcome, +}; + +fn core_fixture() -> CoreModule { + let input = LocalRef { + id: "arg.0".to_owned(), + alpha_name: "input".to_owned(), + ty: "examples.facade@1.Input".to_owned(), + }; + let result = CoreExpr::Local { + reference: input.clone(), + }; + CoreModule { + api_version: "edict.core/v1".to_owned(), + coordinate: "examples.facade@1".to_owned(), + imports: Vec::new(), + types: BTreeMap::from([( + "Input".to_owned(), + CoreType::Record { + fields: BTreeMap::from([("ok".to_owned(), "Bool".to_owned())]), + }, + )]), + intents: BTreeMap::from([( + "echo".to_owned(), + CoreIntent { + input: input.ty.clone(), + output: input.ty.clone(), + required_operation_profile: "continuum.profile.read-only/v1".to_owned(), + basis: Some(result.clone()), + input_constraints: Vec::new(), + core_evaluation_budget: CoreBudget { + max_steps: 8, + max_allocated_bytes: 256, + max_output_bytes: 64, + }, + body: CoreBlock { + locals: vec![input], + nodes: Vec::new(), + result, + }, + }, + )]), + required_core_capabilities: Vec::new(), + } +} + +fn target_fixture(core: &CoreModule) -> TargetIrArtifact { + let intent = &core.intents["echo"]; + TargetIrArtifact { + domain: "echo.span-ir/v1".to_owned(), + target_profile: ResourceRef { + coordinate: "echo.dpo@1".to_owned(), + digest: Some(format!("sha256:{}", "1".repeat(64))), + }, + source_core_coordinate: core.coordinate.clone(), + semantic_closure: Some(TargetIrSemanticClosure { + source_core: ResourceRef { + coordinate: core.coordinate.clone(), + digest: Some( + digest_core_module(core) + .expect("Core digest") + .to_review_string(), + ), + }, + lawpacks: Vec::new(), + capabilities: Vec::new(), + }), + intents: BTreeMap::from([( + "echo".to_owned(), + TargetIrIntent { + operation_profile: intent.required_operation_profile.clone(), + basis: intent.basis.clone(), + input_constraints: intent.input_constraints.clone(), + core_evaluation_budget: intent.core_evaluation_budget.clone(), + pure_bindings: Vec::new(), + requirements: Vec::new(), + steps: Vec::new(), + external_action_requests: Vec::new(), + result: intent.body.result.clone(), + }, + )]), + } +} + +#[test] +fn facade_consumer_constructs_and_verifies_artifacts() { + let core = core_fixture(); + let target = target_fixture(&core); + let projection = ResultProjection { + api_version: "edict.result-projection/v1".to_owned(), + operation_coordinate: "examples.facade@1.echo".to_owned(), + output_type: "examples.facade@1.Input".to_owned(), + max_output_bytes: 64, + expression: ResultProjectionExpr::Source { + source: ResultProjectionSource::ApplicationInput, + path: Vec::new(), + }, + }; + + let core_bytes = encode_core_module(&core).expect("encode consumer Core"); + let target_bytes = encode_target_ir_artifact(&target).expect("encode consumer Target IR"); + let projection_bytes = encode_result_projection(&projection).expect("encode projection"); + let decoded: CanonicalValue = decode_canonical_cbor(&core_bytes).expect("decode Core value"); + assert!(matches!(decoded, CanonicalValue::Map(_))); + assert!(matches!( + decode_canonical_cbor(&target_bytes).expect("decode Target IR value"), + CanonicalValue::Map(_) + )); + assert_eq!( + decode_result_projection(&projection_bytes).expect("decode projection"), + projection + ); + let projection_digest = digest_result_projection(&projection).expect("projection digest"); + let verified: VerifiedResultProjection = + verify_result_projection(&core, &target, "echo", &projection_bytes, projection_digest) + .expect("independently verify consumer projection"); + assert_eq!(verified.projection(), &projection); + assert_eq!(verified.digest(), projection_digest); + assert_ne!( + digest_core_module(&core).expect("Core identity"), + digest_target_ir_artifact(&target).expect("Target IR identity") + ); +} + +#[test] +fn facade_consumer_names_diagnostic_spans() { + let CheckOutcome::ParseFailed(error) = check("package ;") else { + panic!("malformed package must fail parsing"); + }; + let span: Span = error.span; + assert_eq!(span, Span { start: 8, end: 9 }); +} diff --git a/crates/edict/tests/public_surface.rs b/crates/edict/tests/public_surface.rs new file mode 100644 index 0000000..82f21ce --- /dev/null +++ b/crates/edict/tests/public_surface.rs @@ -0,0 +1,92 @@ +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +use edict::{ + check, + diagnostic::{ParseErrorKind, SemanticErrorKind}, + CheckOutcome, +}; + +#[test] +fn curated_facade_checks_source_and_reports_stable_failures() { + assert_eq!( + check("package examples.public_surface@1;\n"), + CheckOutcome::Valid + ); + let CheckOutcome::ParseFailed(error) = check("package ;") else { + panic!("malformed package must fail parsing"); + }; + assert_eq!(error.kind, ParseErrorKind::ExpectedIdentifier); + + let CheckOutcome::SemanticFailed(errors) = + check("package examples.public_surface@1; type Input = { name: String };") + else { + panic!("unbounded string must fail semantic checking"); + }; + let kinds: Vec<_> = errors.iter().map(|error| error.kind).collect(); + assert_eq!(kinds, vec![SemanticErrorKind::UnboundedScalar]); +} + +fn check_consumer(root: &Path, source: &str) -> Output { + fs::write(root.join("src/main.rs"), source).expect("consumer source"); + Command::new("cargo") + .args(["check", "--offline", "--message-format=json"]) + // An independent target directory avoids contending with the parent + // cargo-test invocation; subsequent controls reuse the dependency build. + .arg("--target-dir") + .arg(root.join("target")) + .current_dir(root) + .output() + .expect("check independent facade consumer") +} + +fn compiler_error_codes(output: &Output) -> Vec { + String::from_utf8(output.stdout.clone()) + .expect("Cargo diagnostic stream") + .lines() + .map(|line| serde_json::from_str::(line).expect("Cargo JSON record")) + .filter(|record| record["reason"] == "compiler-message") + .filter(|record| record["message"]["level"] == "error") + .filter_map(|record| { + record["message"]["code"]["code"] + .as_str() + .map(str::to_owned) + }) + .collect() +} + +#[test] +fn implementation_modules_are_unavailable_to_consumers() { + let root = std::env::temp_dir().join(format!("edict-facade-consumer-{}", std::process::id())); + fs::create_dir(&root).expect("fresh consumer workspace"); + fs::create_dir(root.join("src")).expect("consumer source directory"); + let facade_path = + serde_json::to_string(env!("CARGO_MANIFEST_DIR")).expect("quote the facade path for TOML"); + fs::write( + root.join("Cargo.toml"), + format!( + "[package]\nname = \"facade-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\n[workspace]\n[dependencies]\nedict = {{ package = \"flyingrobots-edict\", path = {facade_path} }}\n" + ), + ) + .expect("consumer manifest"); + + let positive = check_consumer( + &root, + "use edict::{check, CheckOutcome}; fn main() { assert_eq!(check(\"package examples.consumer@1;\"), CheckOutcome::Valid); }", + ); + assert!( + positive.status.success(), + "supported consumer must compile: {}", + String::from_utf8_lossy(&positive.stderr) + ); + assert!(compiler_error_codes(&positive).is_empty()); + + let negative = check_consumer( + &root, + "use edict::parser::parse_module; fn main() { let _ = parse_module(\"package examples.consumer@1;\"); }", + ); + assert!(!negative.status.success()); + assert_eq!(compiler_error_codes(&negative), vec!["E0432"]); + fs::remove_dir_all(&root).expect("remove owned consumer workspace"); +} diff --git a/docs/topics/README.md b/docs/topics/README.md index dd1bafe..99b06d3 100644 --- a/docs/topics/README.md +++ b/docs/topics/README.md @@ -68,6 +68,9 @@ cargo xtask verify validation, explicit in-process compatibility adapters for the current built-in target lowerers, the external provider WIT contract, and pure invocation request/result validation with host-authored output identity. +- [Public Rust API](./public-rust-api/README.md): curated non-publishing Rust + facade for source checking, stable diagnostic kinds, and canonical artifact + identities. - [Result Projections](./result-projections/README.md): compiler-owned, canonical, bounded application-result assembly from declared input and capability-result sources with independent reverse verification. diff --git a/docs/topics/documentation/README.md b/docs/topics/documentation/README.md index 1ef6409..34fa7f4 100644 --- a/docs/topics/documentation/README.md +++ b/docs/topics/documentation/README.md @@ -113,6 +113,75 @@ Do not copy live issue lists, pull request lists, CI timestamps, or dashboards into prose as current truth. Link to live systems or use generated artifacts when those facts matter. [DOCS-REQ-005] +## Durable Decision Discipline + +Important decisions are incomplete until their durable owner is current. +Architecture, authority, identity, canonical-format, recovery, compatibility, +ownership, public-API, and release-boundary decisions MUST be recorded in the +same change in the canonical topic shelf, specification, requirement, or release +document that owns the concept. Chat transcripts, Think memories, pull-request +prose, and review threads may explain or motivate a decision, but they are not +its canonical repository home. + +For every such decision: + +1. Identify one canonical owner before completing the change. Prefer the + relevant `docs/topics//README.md` for current behavior, + `architecture.md` for machinery, a normative `docs/SPEC_*.md` or ABI schema + for protocol law, and `test-plan.md` for planned and implemented evidence. +2. Record the accepted rule and whether it is implemented or planned. Put + implemented behavior in its current-truth owner. Put target behavior in + explicitly planned `test-plan.md` rows or a linked design proposal; a topic + `README.md` may link to that future work but must not describe it as current + behavior. Record the decision's refinement, supersession, dependency, and + related-document edges in the page that owns its actual posture. +3. Update `docs/topics/README.md`, `docs/README.md`, or another relevant router + when a durable page or topic shelf is added, moved, or renamed. +4. Link to the canonical owner from reader-specific pages instead of copying + the same rule into several places. +5. Keep implementation checklists, review state, and delivery status in GitHub. + Current topic shelves describe branch or HEAD truth; they are not a second + project tracker. +6. Revisit the same canonical owner whenever later work refines the decision. + A refinement is not complete while code, schemas, packages, fixtures, or + release behavior disagree with the documented rule. + +Treat missing or stale canonical decision documentation as incomplete +engineering work, not optional polish. Historical design and release documents +remain evidence; update the current owning shelf rather than silently relying +on an old decision record. [DOCS-REQ-007] + +### Decision relationship format + +Place a two-column `Relationship` / `Targets` table beside the decision's rule +and posture. Include all four field names below, in this order. Each target is +a Markdown link to the canonical document or section; use the requirement or +decision ID as the link label when one exists. Separate multiple targets with +commas. Write the literal `none` when a relationship has no targets; an omitted +row or blank cell is incomplete. [DOCS-REQ-007] + +| Field | Meaning from this decision to its target | +| --- | --- | +| `refines` | Adds detail or a narrower rule while the target remains authoritative. | +| `supersedes` | Replaces the target rule. Preserve its history and identify this replacement in the prior owner. | +| `depends_on` | Requires the target contract to hold for this decision to hold. | +| `related` | Provides relevant context without asserting refinement, replacement, or dependency. | + +For example, this durable-decision policy has current posture and the following +relationships. It refines the documentation-impact rule by requiring a named +canonical owner; it depends on the existing topic-shelf contract. + +| Relationship | Targets | +| --- | --- | +| `refines` | [DOCS-REQ-005](./test-plan.md#requirements) | +| `supersedes` | none | +| `depends_on` | [Topic shelf contract](../README.md) | +| `related` | [Review process](../review-process/README.md) | + +These edges are reviewed as policy metadata. Link and topic checks verify the +local references and evidence records; they do not prove that a claimed +refinement, supersession, or dependency is semantically correct. + ## Deterministic checks and editorial review The local gate already checks links, topic metadata, evidence names, fixture diff --git a/docs/topics/documentation/test-plan.md b/docs/topics/documentation/test-plan.md index a2d4184..4796495 100644 --- a/docs/topics/documentation/test-plan.md +++ b/docs/topics/documentation/test-plan.md @@ -34,6 +34,7 @@ Out of scope: | DOCS-REQ-004 | policy | Examples distinguish runnable, illustrative, and abridged use; copyable shell commands omit prompts. | docs/topics/documentation/README.md, fixtures/README.md | | DOCS-REQ-005 | policy | Contract-bearing changes update affected docs or declare `docs-impact: none`; source-language and Core semantic changes keep the formal language spec, coupled CDDL, and owning evidence current in the same pull request; changed documentation preserves page type. | AGENTS.md, CONTRIBUTING.md, docs/SPEC_edict-language-v1.md, docs/topics/documentation/README.md | | DOCS-REQ-006 | policy | Documentation quality uses deterministic checks for software facts and human review for reader-task success. | docs/topics/documentation/README.md, xtask/src/contract_check.rs, xtask/src/tests.rs | +| DOCS-REQ-007 | policy | Important durable decisions are complete only when their canonical repository owner and affected entry points are current in the same change. | docs/topics/documentation/README.md | ## Fixtures @@ -56,6 +57,9 @@ Out of scope: | DOCS-TP-003 | policy | Coverage policy | DOCS-REQ-003 | Review confirms the documentation shelf contains an Edict coverage matrix. | - | docs/topics/documentation/README.md | Policy detail; do not encode as a Rust test. | | DOCS-TP-004 | policy | Example and impact policy | DOCS-REQ-004, DOCS-REQ-005 | Review confirms the documentation shelf states runnable example rules, copyable shell command rules, formal language-spec/CDDL/evidence synchronization, `docs-impact: none`, and page-type preservation. | - | CONTRIBUTING.md, docs/SPEC_edict-language-v1.md, docs/topics/documentation/README.md, docs/topics/documentation/test-plan.md | Policy detail; do not encode as a Rust test. | | DOCS-TP-005 | policy | Local gate policy | DOCS-REQ-006 | Review confirms deterministic checks are described as fact checks and behavior tests, with prose quality left to human review. | - | docs/topics/documentation/README.md, docs/topics/documentation/test-plan.md | Tool behavior is covered by existing `contract_graph_*` tests. | +| DOCS-TP-006 | policy | Durable decision ownership | DOCS-REQ-007 | Review identifies one canonical owner and rejects a change whose accepted decision exists only in chat, memory, PR prose, or a stale owning page. | - | AGENTS.md, docs/topics/documentation/README.md | Human workflow contract; existing link and topic checks verify discovery, not whether prose faithfully records a decision. | +| DOCS-TP-007 | policy | Decision posture | DOCS-REQ-007 | Review keeps implemented rules in current-truth pages and planned target rules in explicitly planned evidence or linked proposals; an implemented rule may link to future work without claiming it exists. | - | AGENTS.md, docs/topics/documentation/README.md | Resolve current-versus-target placement without turning topic READMEs into delivery trackers. | +| DOCS-TP-008 | policy | Decision relationships | DOCS-REQ-007 | Review checks all four relationship fields, linked targets with their relationship direction, and explicit `none` for absent edges; superseded rules identify their replacement. | - | docs/topics/documentation/README.md, docs/topics/public-rust-api/README.md | The canonical table format supports consistent human review; no semantic graph checker is claimed. | ## Determinism Obligations diff --git a/docs/topics/public-rust-api/README.md b/docs/topics/public-rust-api/README.md new file mode 100644 index 0000000..c64a108 --- /dev/null +++ b/docs/topics/public-rust-api/README.md @@ -0,0 +1,45 @@ +# Public Rust API + +Status: current HEAD contract. + +The `flyingrobots-edict` package exposes the Rust library name `edict` as a +curated facade over Edict's implementation crates. The facade is the supported +Rust entry point for source checking, stable diagnostic kinds, and canonical +artifact identity operations. It does not expose the implementation crate's +module tree as an accidental public API. + +The package remains `publish = false`. This topic defines a reversible release- +engineering boundary; it does not authorize or claim crates.io publication. + +The `edict` CLI remains the stable process boundary for complete application +builds. The Rust facade does not duplicate the CLI's JSONL protocol, provider +host, filesystem publication, or application-build orchestration. + +Release preparation advances the facade package version, its exact +`edict-syntax` requirement, and both lockfile package entries together. The +prepared workspace remains resolvable with offline, locked Cargo metadata. +[PUBRUST-REQ-004] + +The artifact namespace exports the value models needed to construct Core, +Target IR, and result-projection inputs and to inspect decoded canonical values +and verified projections. Diagnostic spans are available under `diagnostic`. +These are explicit type exports; implementation modules remain private to the +facade boundary. [PUBRUST-REQ-001] + +## Decision relationships + +This current public-API boundary depends on the contracts implemented by its +explicit exports. It adds a curated entry point; the implementation crate +continues to serve repository consumers. + +| Relationship | Targets | +| --- | --- | +| `refines` | none | +| `supersedes` | none | +| `depends_on` | [Syntax](../syntax/README.md), [Semantic validation](../semantic-validation/README.md), [Core IR](../core-ir/README.md), [Target IR](../target-ir/README.md), [Result projections](../result-projections/README.md) | +| `related` | [Rust standards](../rust-standards/README.md), [Release process](../release-process/README.md), [CLI](../cli/README.md) | + +The relationship table follows the +[durable-decision policy](../documentation/README.md#durable-decision-discipline). +Packaging and registry-publication work remain explicitly planned in the +[test plan](./test-plan.md); they are not claims of current publication. diff --git a/docs/topics/public-rust-api/test-plan.md b/docs/topics/public-rust-api/test-plan.md new file mode 100644 index 0000000..4c3b9e9 --- /dev/null +++ b/docs/topics/public-rust-api/test-plan.md @@ -0,0 +1,47 @@ +# Public Rust API Test Plan + +## Scope + +In scope: + +- a curated Rust facade with the library name `edict`; +- source checking and stable diagnostic-kind access; +- canonical Core, Target IR, and result-projection identity access; +- package inventory and clean external-consumer checks; +- an explicit non-publication boundary. + +Out of scope: + +- crates.io publication or crate-name reservation; +- a stable 1.0 API; +- exposing the implementation crate's full module tree; +- replacing the CLI application-build boundary; +- splitting every compiler subsystem into its final crate. + +## Requirements + +| ID | Status | Requirement | Source | +| --- | --- | --- | --- | +| PUBRUST-REQ-001 | implemented | One curated package exposes Edict source checking, stable diagnostic kinds, and canonical artifact identity operations without re-exporting the implementation module tree. | issue #189 | +| PUBRUST-REQ-002 | planned | The facade's package inventory is explicit, reproducible, and remains non-publishing until a separately approved publication policy exists. | issue #189 | +| PUBRUST-REQ-003 | planned | A clean external consumer can compile against the facade without an undocumented repository-relative dependency. | issue #189 | +| PUBRUST-REQ-004 | implemented | Release preparation advances the facade package version and exact implementation dependency together. | xtask/src/release_prep.rs | + +## Test Cases + +| ID | Status | Category | Requirement | Oracle | Evidence | Fixtures | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| PUBRUST-TP-001 | implemented | Public API | PUBRUST-REQ-001 | Source checking accepts valid input and reports stable parse and semantic failure kinds; consumer artifact values encode, decode, digest, and verify. | curated_facade_checks_source_and_reports_stable_failures, facade_consumer_constructs_and_verifies_artifacts | crates/edict/tests/public_surface.rs, crates/edict/tests/artifact_models.rs | Operations execute on valid and invalid inputs; no representation checks. | +| PUBRUST-TP-002 | implemented | Negative compile | PUBRUST-REQ-001 | A facade-only consumer compiles supported imports, then receives Rust E0432 for the implementation parser module. | implementation_modules_are_unavailable_to_consumers | crates/edict/src/lib.rs, crates/edict/tests/public_surface.rs | The independent consumer checks structured compiler diagnostics; the compile-fail doctest remains an additional workspace witness. | +| PUBRUST-TP-003 | planned | Package boundary | PUBRUST-REQ-002 | Packaging succeeds with the reviewed inventory without publishing or mutating registry state. | release-engineering package check | crates/edict/Cargo.toml | The current package inventory dry run succeeds; the complete registry dependency closure remains unpublished. | +| PUBRUST-TP-004 | planned | External consumer | PUBRUST-REQ-003 | The project compiles and runs without a sibling Edict checkout. | release-engineering external-consumer check | - | Requires packaged implementation dependencies or a sealed local registry before publication. | +| PUBRUST-TP-005 | implemented | Release preparation | PUBRUST-REQ-004 | Cargo resolves the requested facade and implementation versions with the prepared lockfile. | release_prep_keeps_facade_exact_dependency_resolvable | xtask/src/tests.rs | Offline temporary workspace; no registry publication. | +| PUBRUST-TP-006 | implemented | Consumer model closure | PUBRUST-REQ-001 | A consumer using only facade imports constructs Core, Target IR, and projection values, names decoded values and verified projections, and reads diagnostic spans. | facade_consumer_constructs_and_verifies_artifacts, facade_consumer_names_diagnostic_spans | crates/edict/tests/artifact_models.rs | The integration test is a separate consumer crate; it uses no implementation imports. | + +## Known Gaps + +- The implementation dependency still needs a permanent registry package name + and a completed dependency-closure dry run before publication can be + considered. +- Registry names, ownership, credentials, and publication automation remain + deliberately unconfigured. diff --git a/docs/topics/release-process/README.md b/docs/topics/release-process/README.md index 379ffe5..a474ab8 100644 --- a/docs/topics/release-process/README.md +++ b/docs/topics/release-process/README.md @@ -64,6 +64,9 @@ release files are read or written. Without `--date`, the CLI supplies the curren clock to the scaffold helper, which derives its UTC date. Pre-epoch clocks fail as `ClockBeforeEpoch`; clocks beyond the four-digit year range fail as `ClockOutOfRange`. An explicit valid date takes precedence over the clock. +The facade package version, exact implementation dependency, and lockfile entry +advance with the implementation and CLI package versions, so the prepared +workspace remains resolvable. [RELEASE-REQ-024] The policy structural guard preserves the historical release identities from @@ -73,7 +76,9 @@ by keeping the total count unchanged. Git tag reconciliation separately checks coverage for every actual tagged release. Required `scope` and `non_goals` values are parsed as TOML string arrays; comments and string contents cannot satisfy field presence. The structural guard and date reconciliation use the -same parsed fields. [RELEASE-REQ-025] +same parsed fields. Policy target dates also use release preparation's calendar +validator, so impossible month-end and leap-day values reject in published, +prep, and planned blocks. [RELEASE-REQ-025] `cargo xtask release-dates` reconciles the dates recorded in the release policy, `CHANGELOG.md`, and `docs/releases/*.md` against the git tags that published diff --git a/docs/topics/release-process/test-plan.md b/docs/topics/release-process/test-plan.md index 574bee8..61ecded 100644 --- a/docs/topics/release-process/test-plan.md +++ b/docs/topics/release-process/test-plan.md @@ -114,6 +114,10 @@ Out of scope: | RELEASE-TP-029 | implemented | Published tag coverage | RELEASE-REQ-008 | Removing one published release tag fails even while another remains; prep and planned blocks without tags remain valid. | release_date_reconciliation_rejects_missing_published_tags, release_date_reconciliation_allows_untagged_preparation | xtask/src/tests.rs | Tag presence is checked from published policy blocks back to the tag inventory. | | RELEASE-TP-030 | implemented | Parsed policy fields | RELEASE-REQ-008, RELEASE-REQ-025 | Comments and string contents cannot satisfy scope/non_goals list presence; actual string arrays are accepted independent of assignment spacing. | release_policy_list_presence_requires_actual_assignments, release_policy_lists_accept_toml_assignment_spacing | xtask/src/release_dates.rs, xtask/src/tests.rs | Reconciliation and structural validation consume the same parsed list fields. | +| RELEASE-TP-031 | implemented | Package versions | RELEASE-REQ-024 | After release preparation, Cargo resolves the facade exact implementation dependency with the requested package versions and an unchanged lockfile. | release_prep_keeps_facade_exact_dependency_resolvable | xtask/src/tests.rs | Offline Cargo metadata over a temporary workspace checks package versions, dependency requirements, and lockfile consistency. | + +| RELEASE-TP-032 | implemented | Policy calendar dates | RELEASE-REQ-025 | Policy date validation rejects impossible month-end and leap-day values while accepting real leap days and canonical ordinary dates. | release_policy_dates_require_real_calendar_days | xtask/src/tests.rs, xtask/src/release_prep.rs | The structural guard uses the same calendar judgment as release preparation, including for untagged prep and planned blocks. | + ## Determinism Obligations - Release workflow contract tests inspect checked-in workflow text, not live diff --git a/fixtures/providers/components/inventory.json b/fixtures/providers/components/inventory.json index 726f438..3ac2f3b 100644 --- a/fixtures/providers/components/inventory.json +++ b/fixtures/providers/components/inventory.json @@ -7,5 +7,5 @@ "malformed-lowerer": "sha256:dfcd171918373d18b9dff16778e98b7618eeb4ac85976dd7134b9e201562f41b", "verifier": "sha256:9fa8e16ed7735075d559e3094685ce846d06425b4bb479be31f7498417bf87e4" }, - "sourceDigest": "sha256:8b27380560362a3b72d40dd8e2a9bbe475d21ce760b0b7ba19f0d6ccf41a6d52" + "sourceDigest": "sha256:2775871607270742222e58ea8b9e39ec2ba7e7bb29ac2bb599192001ba52f9ba" } diff --git a/xtask/src/release_prep.rs b/xtask/src/release_prep.rs index ad74de9..01ed64c 100644 --- a/xtask/src/release_prep.rs +++ b/xtask/src/release_prep.rs @@ -3,6 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use regex::Regex; + use crate::util::read_to_string; #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +68,7 @@ pub(crate) fn release_prep( None => format!("{kind:?}"), })?; let cli_manifest_path = root.join("crates/edict-cli/Cargo.toml"); + let facade_manifest_path = root.join("crates/edict/Cargo.toml"); let syntax_manifest_path = root.join("crates/edict-syntax/Cargo.toml"); let lockfile_path = root.join("Cargo.lock"); let changelog_path = root.join("CHANGELOG.md"); @@ -89,6 +92,10 @@ pub(crate) fn release_prep( &read_to_string(&syntax_manifest_path)?, &version.package_version, )?; + let facade_manifest = replace_facade_manifest_version( + &read_to_string(&facade_manifest_path)?, + &version.package_version, + )?; let lockfile = replace_lock_package_versions(&read_to_string(&lockfile_path)?, &version.package_version)?; let changelog = insert_release_changelog_section( @@ -102,6 +109,7 @@ pub(crate) fn release_prep( write_file(&cli_manifest_path, &cli_manifest)?; write_file(&syntax_manifest_path, &syntax_manifest)?; + write_file(&facade_manifest_path, &facade_manifest)?; write_file(&lockfile_path, &lockfile)?; write_file(&changelog_path, &changelog)?; write_file(&policy_path, &policy)?; @@ -139,6 +147,24 @@ fn replace_first_version_line(text: &str, package_version: &str) -> Result Result { + let text = replace_first_version_line(text, package_version)?; + // The facade declares its exact implementation requirement in an inline + // dependency table. Reject an absent or ambiguous requirement before writes. + let pattern = Regex::new( + r#"(?m)^([ \t]*edict-syntax[ \t]*=[ \t]*\{[^\r\n}]*\bversion[ \t]*=[ \t]*)"[^"]*""#, + ) + .map_err(|error| format!("facade dependency pattern: {error}"))?; + if pattern.captures_iter(&text).count() != 1 { + return Err("facade manifest must declare one inline edict-syntax version".into()); + } + Ok(pattern + .replace(&text, |captures: ®ex::Captures<'_>| { + format!("{}\"={package_version}\"", &captures[1]) + }) + .into_owned()) +} + fn replace_lock_package_versions(text: &str, package_version: &str) -> Result { let mut current_package = None; let mut replaced = BTreeSet::new(); @@ -157,7 +183,7 @@ fn replace_lock_package_versions(text: &str, package_version: &str) -> Result Result Result<(), ReleasePrepDateError> { +pub(crate) fn validate_iso_date(date: &str) -> Result<(), ReleasePrepDateError> { let bytes = date.as_bytes(); if bytes.len() != 10 || !bytes.iter().enumerate().all(|(index, byte)| { diff --git a/xtask/src/tests.rs b/xtask/src/tests.rs index d25ed6f..a5b238b 100644 --- a/xtask/src/tests.rs +++ b/xtask/src/tests.rs @@ -1793,6 +1793,53 @@ fn release_prep_scaffolds_version_policy_changelog_and_notes() { ); } +#[test] +fn release_prep_keeps_facade_exact_dependency_resolvable() { + let root = temp_root("release-prep-facade"); + write_release_prep_scaffold_fixture(&root); + release_prep( + &root, + "v0.12.0-alpha.1", + Some("2026-08-04"), + std::time::UNIX_EPOCH, + ) + .expect("release prep scaffold"); + + let lockfile_before = fs::read(root.join("Cargo.lock")).expect("prepared lockfile"); + let output = Command::new("cargo") + .args(["metadata", "--offline", "--locked", "--format-version", "1"]) + .current_dir(&root) + .output() + .expect("cargo metadata for prepared workspace"); + assert!( + output.status.success(), + "prepared workspace must resolve: {}", + String::from_utf8_lossy(&output.stderr) + ); + let metadata: Value = serde_json::from_slice(&output.stdout).expect("Cargo metadata"); + let packages = metadata["packages"].as_array().expect("packages"); + assert_eq!(packages.len(), 3); + for package in packages { + assert_eq!(package["version"], "0.12.0-alpha.1", "{}", package["name"]); + } + let facade = packages + .iter() + .find(|package| package["name"] == "flyingrobots-edict") + .expect("facade package"); + let syntax = facade["dependencies"] + .as_array() + .expect("facade dependencies") + .iter() + .find(|dependency| dependency["name"] == "edict-syntax") + .expect("facade implementation dependency"); + assert_eq!(syntax["req"], "=0.12.0-alpha.1"); + assert_eq!( + fs::read(root.join("Cargo.lock")).expect("checked lockfile"), + lockfile_before, + "metadata must not repair the prepared lockfile" + ); +} + #[test] fn release_prep_rejects_existing_release_notes_before_writing() { let root = temp_root("release-prep-existing-notes"); @@ -1805,6 +1852,7 @@ fn release_prep_rejects_existing_release_notes_before_writing() { let tracked = [ "crates/edict-cli/Cargo.toml", "crates/edict-syntax/Cargo.toml", + "crates/edict/Cargo.toml", "Cargo.lock", "CHANGELOG.md", "docs/topics/release-process/policy.toml", @@ -1843,6 +1891,22 @@ fn release_prep_rejects_existing_release_notes_before_writing() { } fn write_release_prep_scaffold_fixture(root: &Path) { + fs::create_dir_all(root).expect("workspace dir"); + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/edict-cli\", \"crates/edict-syntax\", \"crates/edict\"]\nresolver = \"3\"\n", + ) + .expect("workspace manifest"); + for package in ["edict-cli", "edict-syntax", "edict"] { + let source = root.join("crates").join(package).join("src"); + fs::create_dir_all(&source).expect("package source dir"); + fs::write(source.join("lib.rs"), "").expect("library target"); + } + fs::write( + root.join("crates/edict/Cargo.toml"), + "[package]\nname = \"flyingrobots-edict\"\nversion = \"0.11.0-alpha.1\"\n[dependencies]\nedict-syntax = { path = \"../edict-syntax\", version = \"=0.11.0-alpha.1\" }\n", + ) + .expect("facade manifest"); fs::create_dir_all(root.join("crates/edict-cli")).expect("edict-cli dir"); fs::create_dir_all(root.join("crates/edict-syntax")).expect("edict-syntax dir"); fs::create_dir_all(root.join("docs/releases")).expect("release notes dir"); @@ -1860,7 +1924,7 @@ fn write_release_prep_scaffold_fixture(root: &Path) { .expect("edict-syntax manifest"); fs::write( root.join("Cargo.lock"), - "version = 4\n\n[[package]]\nname = \"edict-cli\"\nversion = \"0.11.0-alpha.1\"\n\n[[package]]\nname = \"edict-syntax\"\nversion = \"0.11.0-alpha.1\"\n", + "version = 4\n\n[[package]]\nname = \"edict-cli\"\nversion = \"0.11.0-alpha.1\"\n\n[[package]]\nname = \"edict-syntax\"\nversion = \"0.11.0-alpha.1\"\n\n[[package]]\nname = \"flyingrobots-edict\"\nversion = \"0.11.0-alpha.1\"\ndependencies = [\n \"edict-syntax\",\n]\n", ) .expect("lockfile"); fs::write( @@ -3245,15 +3309,27 @@ fn annotated_tag(date: &str) -> crate::release_dates::TagRecord { } } +#[test] +fn release_policy_dates_require_real_calendar_days() { + for (value, expected) in [ + ("2026-02-30", false), + ("2026-02-29", false), + ("1900-02-29", false), + ("2026-04-31", false), + ("2026-00-01", false), + ("2026-13-01", false), + ("2026-01-00", false), + ("2026-1-001", false), + ("2026-02-28", true), + ("2000-02-29", true), + ("2024-02-29", true), + ] { + assert_eq!(is_iso_date(value), expected, "{value}"); + } +} + fn is_iso_date(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == 10 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes - .iter() - .enumerate() - .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + crate::release_prep::validate_iso_date(value).is_ok() } fn wit_named_type(interface: &Interface, name: &str) -> TypeId {