From 87697fa512554e72af8ab2abbb14dbed57c750bd Mon Sep 17 00:00:00 2001 From: "Claude Fable 5 (claude-fable-5)" Date: Thu, 27 Aug 2026 20:13:38 +0200 Subject: [PATCH] feat: ship schemas for proposal, tasks, and design artifacts Every change artifact now validates against an embedded mdschema with the same repo-override path the spec schemas use: a proposal carries Why, What Changes, Capabilities, and Impact with status frontmatter, tasks carry numbered sections of N.M checkboxes, and a design carries its approach. Validation wires them through validate_change, so tree and per-change runs check identically. A wiring test pins the three diagnostic codes. Co-Authored-By: Martin Zeman --- schemas/design.mdschema | 23 +++++++++++++++ schemas/proposal.mdschema | 35 ++++++++++++++++++++++ schemas/tasks.mdschema | 17 +++++++++++ src/spec/templates.rs | 12 ++++++++ src/spec/tests.rs | 24 +++++++++++++++ src/spec/validate.rs | 62 +++++++++++++++++++++++++++++++++------ 6 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 schemas/design.mdschema create mode 100644 schemas/proposal.mdschema create mode 100644 schemas/tasks.mdschema diff --git a/schemas/design.mdschema b/schemas/design.mdschema new file mode 100644 index 0000000..4c11415 --- /dev/null +++ b/schemas/design.mdschema @@ -0,0 +1,23 @@ +# Change design structure: the approach with its beaten alternative, +# and any further sections the change needs. + +heading_rules: + no_skip_levels: true + max_depth: 3 + +structure: + - heading: + pattern: "^# .+" + regex: true + count: + max: 1 + allow_additional: true + children: + - heading: "## Approach" + severity: warning + word_count: + min: 8 + - heading: "## Structure" + optional: true + - heading: "## Risks" + optional: true diff --git a/schemas/proposal.mdschema b/schemas/proposal.mdschema new file mode 100644 index 0000000..e7c735a --- /dev/null +++ b/schemas/proposal.mdschema @@ -0,0 +1,35 @@ +# Change proposal structure: Why states the rationale, What Changes +# and Impact list the observable effects, Capabilities names the +# touched capability specs. + +frontmatter: + fields: + - name: status + type: string + - name: adr + type: string + optional: true + +heading_rules: + no_skip_levels: true + max_depth: 2 + +structure: + - heading: + pattern: "^# .+" + regex: true + count: + max: 1 + children: + - heading: "## Why" + word_count: + min: 10 + - heading: "## What Changes" + lists: + - min: 1 + - heading: "## Capabilities" + lists: + - min: 1 + - heading: "## Impact" + lists: + - min: 1 diff --git a/schemas/tasks.mdschema b/schemas/tasks.mdschema new file mode 100644 index 0000000..402753c --- /dev/null +++ b/schemas/tasks.mdschema @@ -0,0 +1,17 @@ +# Change tasks structure: numbered sections of checkbox items in the +# `- [ ] N.M description` form. + +heading_rules: + max_depth: 2 + +structure: + - heading: + pattern: "^## [0-9]+\\. .+" + regex: true + count: + min: 1 + lists: + - min: 1 + required_text: + - pattern: "^- \\[[ x]\\] [0-9]+\\.[0-9]+ " + regex: true diff --git a/src/spec/templates.rs b/src/spec/templates.rs index 3733205..d4af820 100644 --- a/src/spec/templates.rs +++ b/src/spec/templates.rs @@ -26,3 +26,15 @@ pub(super) const DELTA_SPEC_MDSCHEMA: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/schemas/delta-spec.mdschema" )); +pub(super) const PROPOSAL_MDSCHEMA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/schemas/proposal.mdschema" +)); +pub(super) const TASKS_MDSCHEMA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/schemas/tasks.mdschema" +)); +pub(super) const DESIGN_MDSCHEMA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/schemas/design.mdschema" +)); diff --git a/src/spec/tests.rs b/src/spec/tests.rs index a787a25..0082b13 100644 --- a/src/spec/tests.rs +++ b/src/spec/tests.rs @@ -284,6 +284,30 @@ fn nested_capabilities_are_discovered_validated_and_archived() { assert!(merged.contains("### Requirement: Card payment")); } +#[test] +fn change_artifacts_run_through_their_schemas() { + fn flag_every_file(_content: &str, file_path: &str, _schema: &str) -> Vec { + vec![MdschemaDiagnostic { + file: file_path.to_string(), + line: None, + severity: DiagnosticSeverity::Warning, + message: "stub".to_string(), + }] + } + let root = TempDir::new().unwrap(); + write_change(root.path(), "add-search", "- [ ] 1.1 pending\n", DELTA); + + let diagnostics = validate_spec_tree(root.path(), flag_every_file).unwrap(); + let codes: Vec<&str> = diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect(); + + assert!(codes.contains(&"delta-schema-invalid")); + assert!(codes.contains(&"proposal-schema-invalid")); + assert!(codes.contains(&"tasks-schema-invalid")); +} + #[test] fn abandon_stamps_frontmatter_and_does_not_merge() { let root = TempDir::new().unwrap(); diff --git a/src/spec/validate.rs b/src/spec/validate.rs index 7626f75..dcc4fd5 100644 --- a/src/spec/validate.rs +++ b/src/spec/validate.rs @@ -3,7 +3,9 @@ //! interop artifacts surfaced as warnings. One pipeline serves `validate`, //! archive preflight, and doctor, so acceptance cannot differ by command. -use super::templates::{DELTA_SPEC_MDSCHEMA, SPEC_MDSCHEMA}; +use super::templates::{ + DELTA_SPEC_MDSCHEMA, DESIGN_MDSCHEMA, PROPOSAL_MDSCHEMA, SPEC_MDSCHEMA, TASKS_MDSCHEMA, +}; use super::{ DiagnosticSeverity, PrefixMatch, SpecRoot, SpecViolation, changes_root, discover_capabilities_below, evaluate_change, load_with_override, parse_canonical, @@ -103,6 +105,19 @@ pub(super) fn validate_spec_target( "schemas/delta-spec.mdschema", DELTA_SPEC_MDSCHEMA, )?; + let (proposal_schema, _) = + load_with_override(repository, "schemas/proposal.mdschema", PROPOSAL_MDSCHEMA)?; + let (tasks_schema, _) = + load_with_override(repository, "schemas/tasks.mdschema", TASKS_MDSCHEMA)?; + let (design_schema, _) = + load_with_override(repository, "schemas/design.mdschema", DESIGN_MDSCHEMA)?; + let schemas = ChangeSchemas { + spec: &spec_schema, + delta: &delta_schema, + proposal: &proposal_schema, + tasks: &tasks_schema, + design: &design_schema, + }; let mut diagnostics = Vec::new(); match &target { @@ -126,8 +141,7 @@ pub(super) fn validate_spec_target( validate_change( &spec_root, &change_dir, - &spec_schema, - &delta_schema, + &schemas, false, mdschema_check, &mut diagnostics, @@ -139,8 +153,7 @@ pub(super) fn validate_spec_target( validate_change( &spec_root, &spec_root.changes().join(change), - &spec_schema, - &delta_schema, + &schemas, true, mdschema_check, &mut diagnostics, @@ -273,11 +286,18 @@ fn validate_canonical( Ok(()) } +struct ChangeSchemas<'schemas> { + spec: &'schemas str, + delta: &'schemas str, + proposal: &'schemas str, + tasks: &'schemas str, + design: &'schemas str, +} + fn validate_change( spec_root: &SpecRoot, change_dir: &Path, - spec_schema: &str, - delta_schema: &str, + schemas: &ChangeSchemas<'_>, validate_referenced_canonical_schema: bool, mdschema_check: MdschemaCheck, diagnostics: &mut Vec, @@ -301,7 +321,31 @@ fn validate_change( change: Some(change), }, &content, - delta_schema, + schemas.delta, + mdschema_check, + diagnostics, + ); + } + for (artifact, schema, code) in [ + ("proposal.md", schemas.proposal, "proposal-schema-invalid"), + ("tasks.md", schemas.tasks, "tasks-schema-invalid"), + ("design.md", schemas.design, "design-schema-invalid"), + ] { + let path = change_dir.join(artifact); + if !path.is_file() { + continue; + } + let content = read(&path)?; + append_schema_diagnostics( + SchemaDiagnosticContext { + repository: spec_root.repository(), + path: &path, + code, + capability: None, + change: Some(change), + }, + &content, + schema, mdschema_check, diagnostics, ); @@ -319,7 +363,7 @@ fn validate_change( change: Some(change), }, &content, - spec_schema, + schemas.spec, mdschema_check, diagnostics, );