diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eea79d3..1106470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,5 +17,5 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features --locked - run: cargo build --workspace --release --locked - - run: scripts/ndf check --root tests/fixtures/markdown/project --format json + - run: scripts/ndf check --root tests/fixtures/cli-smoke/project --format json - run: git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ac414..9408650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ All notable changes to NDF are documented here. - JSON workflows for build, check, show, trace, coverage, dependency reports, semantic diff, export, ID allocation, and safe fixes. - Exact dependency locking and no-regression diagnostic baselines. +- Read-only graph dependencies plus `conforms-to` and expected-absent `tracks` + relationships for cross-project contracts. +- Stable PTO instruction identities with indexed encoding, semantics, legality, + fault, completion, architectural-state, and memory-ordering facets. +- Deterministic PTO release impact analysis and the `ndf impact pto-release` + command, including complete conformance/watch inventories and transitive + consumer impact. +- Cross-project diagnostics `NDF-XREF-001` through `NDF-XREF-004` and PTO + instruction identity collision diagnostic `NDF-ASL-005`. ### Changed @@ -22,6 +31,9 @@ All notable changes to NDF are documented here. `status`. - SQLite is defined as derived, disposable state rather than an authored authority. +- Dependency graphs remain separate namespaces and are never merged into a + consumer index; every graph dependency is loaded only from an exact, clean + Git revision. ### Compatibility @@ -31,3 +43,5 @@ All notable changes to NDF are documented here. PTO adapter. - NDF has no supported Python package, import API, or PyO3 compatibility layer. - Format, IR, JSON output, CLI, and plugin surfaces remain experimental. +- This release does not change format 0.2, canonical IR 0.1, JSON envelope + schema 0.1, SQLite schema v1, or the independently versioned plugin API. diff --git a/README.md b/README.md index fe2ef9a..3a95a22 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,58 @@ workflows. `ndf-cli` is the only supported automation interface. NDF ships no Python package or Python bindings; downstream tools invoke the versioned JSON CLI from an exact Git pin. +### PTO release impact + +Compare two immutable PTO indexes against one consumer index with: + +```bash +ndf impact pto-release \ + --before pto-before.sqlite \ + --after pto-after.sqlite \ + --consumer-index davincioo.sqlite \ + --format json +``` + +The two PTO indexes are authoritative release snapshots. The consumer index is +the authoritative inventory of `conforms-to` relationships and +`expected=absent` `tracks` watches. The command only reads these three indexes; +it does not edit sources, indexes, lock files, or dependency checkouts. + +The result is `compatible` when changes do not affect a consumer contract, +`review-required` for moved or superseded targets and newly present watched +identities, and `breaking` when a conformance target is removed or semantically +modified. The report includes direct and transitively related consumers in a +deterministic order so CI and release tooling can compare its JSON byte for +byte. + +### Cross-project conformance + +Declare an external graph only when document relationships must resolve into +that dependency: + +```yaml +dependencies: + pto-spec: + path: deps/pto-spec + graph: true +``` + +The matching `ndf.lock` entry must name the dependency project and its full +40-character Git commit. NDF verifies that the checkout is exact and clean, +loads its graph read-only, and keeps its nodes out of the consumer index. + +Consumer Markdown can then use a stable external URI: + +```markdown + +``` + +Use `tracks=ndf://... expected=absent` when a consumer intentionally watches +for an upstream identity that does not exist yet. `NDF-XREF-001` reports an +invalid watch contract, `NDF-XREF-002` a missing conformance target, +`NDF-XREF-003` an undeclared graph namespace, and `NDF-XREF-004` a watched +identity that has appeared. + ## Documentation - [English specification](normative_language.md) @@ -37,3 +89,5 @@ CLI from an exact Git pin. Format 0.2, canonical IR 0.1, JSON output schema 0.1, CLI spelling, and the plugin API are experimental. Downstream repositories consume NDF from an exact Git commit recorded in `ndf.lock`; CI must not fetch an unpinned branch. +This cross-project release keeps format 0.2, canonical IR 0.1, JSON envelope +schema 0.1, SQLite schema v1, and the plugin API version unchanged. diff --git a/crates/ndf-cli/src/args.rs b/crates/ndf-cli/src/args.rs index 302c247..5625806 100644 --- a/crates/ndf-cli/src/args.rs +++ b/crates/ndf-cli/src/args.rs @@ -23,6 +23,9 @@ impl Arguments { ReportCommand::Dependencies { .. } => "report dependencies", }, Some(Command::Diff { .. }) => "diff", + Some(Command::Impact { command }) => match command { + ImpactCommand::PtoRelease { .. } => "impact pto-release", + }, Some(Command::Export { .. }) => "export", Some(Command::Id { .. }) => "id allocate", Some(Command::Fix { .. }) => "fix", @@ -73,6 +76,10 @@ pub enum Command { #[arg(long, value_enum, default_value_t = JsonFormat::Json)] format: JsonFormat, }, + Impact { + #[command(subcommand)] + command: ImpactCommand, + }, Export { #[arg(long)] index: PathBuf, @@ -94,6 +101,20 @@ pub enum Command { }, } +#[derive(Debug, Subcommand)] +pub enum ImpactCommand { + PtoRelease { + #[arg(long)] + before: PathBuf, + #[arg(long)] + after: PathBuf, + #[arg(long)] + consumer_index: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, +} + #[derive(Debug, Subcommand)] pub enum ReportCommand { Coverage { diff --git a/crates/ndf-cli/src/run.rs b/crates/ndf-cli/src/run.rs index 3b584f3..24b16d4 100644 --- a/crates/ndf-cli/src/run.rs +++ b/crates/ndf-cli/src/run.rs @@ -4,22 +4,23 @@ use std::process::Command as ProcessCommand; use ndf_compiler::CompilerError; use ndf_compiler::adapters::parse_project; -use ndf_compiler::dependencies::resolve_dependencies; +use ndf_compiler::dependencies::{load_dependency_graphs, resolve_dependencies}; use ndf_compiler::diff::semantic_diff; use ndf_compiler::export::export_graph; use ndf_compiler::fix::{FixMode, markdown_paths, safe_fix}; use ndf_compiler::id_allocator::allocate_id; use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; use ndf_compiler::manifest::{ProjectLock, ProjectManifest}; +use ndf_compiler::pto_impact::pto_release_impact; use ndf_compiler::query::trace; use ndf_compiler::report::{CoveragePolicy, coverage}; -use ndf_compiler::rules::{ValidationPolicy, validate_graph}; +use ndf_compiler::rules::{ValidationPolicy, validate_project_graph}; use ndf_core::identity::NodeId; use ndf_core::model::{Diagnostic, DiagnosticSeverity, Graph}; use ndf_core::version::{FORMAT_VERSION, IR_VERSION, TOOL_VERSION}; use serde_json::{Value, json}; -use crate::args::{Arguments, Command, IdCommand, ReportCommand}; +use crate::args::{Arguments, Command, IdCommand, ImpactCommand, ReportCommand}; use crate::envelope::Envelope; pub fn run(arguments: Arguments) -> Result { @@ -115,6 +116,24 @@ pub fn run(arguments: Arguments) -> Result { Envelope::success("diff", semantic_diff(&before, &after)?, Vec::new()) .map_err(Into::into) } + Some(Command::Impact { command }) => match command { + ImpactCommand::PtoRelease { + before, + after, + consumer_index, + format: _, + } => { + let before = NdfIndex::open(&before)?; + let after = NdfIndex::open(&after)?; + let consumer = NdfIndex::open(&consumer_index)?; + Envelope::success( + "impact pto-release", + pto_release_impact(&before, &after, &consumer)?, + Vec::new(), + ) + .map_err(Into::into) + } + }, Some(Command::Export { index, output, @@ -182,8 +201,10 @@ pub fn run(arguments: Arguments) -> Result { fn project_graph(root: &Path) -> Result<(ProjectManifest, Graph), CompilerError> { let manifest = ProjectManifest::load(&root.join("ndf.yaml"))?; + let lock = ProjectLock::load(&root.join("ndf.lock"))?; + let dependencies = load_dependency_graphs(&manifest, &lock, root)?; let parsed = parse_project(&manifest)?; - let diagnostics = validate_graph(&parsed, &ValidationPolicy::default()); + let diagnostics = validate_project_graph(&parsed, &dependencies, &ValidationPolicy::default()); Ok(( manifest, Graph { diff --git a/crates/ndf-cli/tests/commands.rs b/crates/ndf-cli/tests/commands.rs index f5b3358..f04077f 100644 --- a/crates/ndf-cli/tests/commands.rs +++ b/crates/ndf-cli/tests/commands.rs @@ -1,7 +1,11 @@ +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use ndf_compiler::index::{BuildProvenance, build_index}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; use serde_json::Value; fn repository() -> PathBuf { @@ -33,11 +37,194 @@ fn assert_envelope(status: i32, payload: &Value, command: &str) { assert!(payload["diagnostics"].is_array()); } +fn git(path: &Path, arguments: &[&str]) -> String { + let output = Command::new("git") + .args(arguments) + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn initialize_pto_dependency(root: &Path) -> String { + let dependency = root.join("deps/pto-spec"); + fs::create_dir_all(dependency.join("asl")).unwrap(); + git(&dependency, &["init", "-q"]); + git( + &dependency, + &["config", "user.email", "ndf@example.invalid"], + ); + git(&dependency, &["config", "user.name", "NDF Test"]); + fs::write( + dependency.join("ndf.yaml"), + "format_version: '0.2'\nproject: pto-spec\nroots: ['asl/**/*.asl']\nid_prefixes: [PTO]\ndomains: [architecture]\npolicies: {}\ndependencies: {}\n", + ) + .unwrap(); + fs::write( + dependency.join("asl/contracts.asl"), + r#"// NDF-BEGIN: PTO-CONTRACT-001 +// ndf: kind=contract level=L1 layer=architecture status=accepted +// The released architecture contract is authoritative. +// NDF-END: PTO-CONTRACT-001 +// NDF-BEGIN: PTO-INST-TILE-TLOAD +// ndf: kind=contract level=L1 layer=tile status=accepted +// TLOAD is an accepted tile instruction. +// NDF-END: PTO-INST-TILE-TLOAD +"#, + ) + .unwrap(); + git(&dependency, &["add", "ndf.yaml", "asl/contracts.asl"]); + git(&dependency, &["commit", "-qm", "PTO graph fixture"]); + git(&dependency, &["rev-parse", "HEAD"]) +} + +fn write_lock(root: &Path, revision: &str) { + fs::write( + root.join("ndf.lock"), + format!( + "format_version: '0.1'\ndependencies:\n pto-spec:\n uri: https://example.invalid/pto-spec.git\n revision: {revision}\n path: deps/pto-spec\n" + ), + ) + .unwrap(); +} + +fn impact_node(project: &str, local_id: &str, handler: Option<&str>) -> Node { + let mut attributes = BTreeMap::new(); + if let Some(handler) = handler { + attributes.insert("semantic_handler".to_owned(), handler.to_owned()); + attributes.insert( + "semantic_facets".to_owned(), + "instruction-semantics".to_owned(), + ); + } + Node { + id: NodeId::new(project, local_id).unwrap(), + kind: NodeKind::Architecture, + title: local_id.to_owned(), + source: SourceSpan::new(format!("docs/{local_id}.md"), 1).unwrap(), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: None, + body: String::new(), + attributes, + } +} + +fn impact_provenance(project: &str) -> BuildProvenance { + BuildProvenance { + project_commits: vec![(project.to_owned(), "0".repeat(40))], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + } +} + +fn impact_indexes(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let before = root.join("before.sqlite"); + let after = root.join("after.sqlite"); + let consumer = root.join("consumer.sqlite"); + let target = "PTO-INST-TILE-TLOAD"; + build_index( + &Graph { + nodes: vec![impact_node("pto-spec", target, Some("TLOAD"))], + ..Graph::default() + }, + &before, + &impact_provenance("pto-spec"), + ) + .unwrap(); + build_index( + &Graph { + nodes: vec![impact_node("pto-spec", target, Some("ExecuteTileLoadV2"))], + ..Graph::default() + }, + &after, + &impact_provenance("pto-spec"), + ) + .unwrap(); + let consumer_node = impact_node("davincioo", "DAV-ISA-TLOAD", None); + build_index( + &Graph { + edges: vec![ + Edge::new( + consumer_node.id.clone(), + NodeId::new("pto-spec", target).unwrap(), + "conforms-to", + None, + ) + .unwrap(), + ], + nodes: vec![consumer_node], + diagnostics: Vec::new(), + }, + &consumer, + &impact_provenance("davincioo"), + ) + .unwrap(); + (before, after, consumer) +} + +fn cross_project_fixture(root: &Path, target: &str) { + let revision = initialize_pto_dependency(root); + + fs::create_dir_all(root.join("docs")).unwrap(); + fs::write( + root.join("docs/consumer.md"), + format!( + r#"--- +doc_id: DOC-CONSUMER +status: active +authority: normative +owner: architecture +--- + +# Consumer + +## External contract {{#REQ-001}} + + +The consumer MUST preserve the external contract. +"# + ), + ) + .unwrap(); + fs::write( + root.join("ndf.yaml"), + "format_version: '0.2'\nproject: consumer\nroots: ['docs/**/*.md']\nid_prefixes: [DOC, REQ]\ndomains: []\npolicies: {}\ndependencies:\n pto-spec:\n path: deps/pto-spec\n graph: true\n", + ) + .unwrap(); + write_lock(root, &revision); +} + +fn graph_command_fixture(root: &Path) { + let revision = initialize_pto_dependency(root); + fs::create_dir_all(root.join("docs")).unwrap(); + fs::copy( + repository().join("tests/fixtures/markdown/project/docs/pipeline.md"), + root.join("docs/pipeline.md"), + ) + .unwrap(); + fs::write( + root.join("ndf.yaml"), + "format_version: '0.2'\nproject: fixture\nroots: ['docs/**/*.md']\nid_prefixes: [DOC, PIPE]\ndomains: [core]\npolicies: {}\ndependencies:\n pto-spec:\n path: deps/pto-spec\n graph: true\n", + ) + .unwrap(); + write_lock(root, &revision); +} + #[test] fn graph_commands_emit_versioned_machine_envelopes() { let directory = tempfile::tempdir().unwrap(); - let fixture = repository().join("tests/fixtures/markdown/project"); - let root = fixture.to_string_lossy(); + graph_command_fixture(directory.path()); + let root = directory.path().to_string_lossy(); let commands: &[(&[&str], &str)] = &[ ( &[ @@ -177,3 +364,60 @@ fn dependency_report_reads_an_exact_local_checkout() { assert_envelope(status, &payload, "report dependencies"); assert_eq!(payload["data"][0]["revision"], revision); } + +#[test] +fn check_rejects_a_missing_cross_project_conformance_target() { + let directory = tempfile::tempdir().unwrap(); + cross_project_fixture(directory.path(), "PTO-MISSING-001"); + let root = directory.path().to_string_lossy(); + + let (status, payload) = ndf( + directory.path(), + &["check", "--root", &root, "--format", "json"], + ); + + assert_eq!(status, 1, "{payload}"); + assert_eq!(payload["ok"], false); + assert_eq!(payload["diagnostics"][0]["code"], "NDF-XREF-002"); +} + +#[test] +fn pto_release_impact_emits_a_deterministic_versioned_envelope() { + let directory = tempfile::tempdir().unwrap(); + let (before, after, consumer) = impact_indexes(directory.path()); + let arguments = [ + "impact", + "pto-release", + "--before", + before.to_str().unwrap(), + "--after", + after.to_str().unwrap(), + "--consumer-index", + consumer.to_str().unwrap(), + "--format", + "json", + ]; + + let (first_status, first) = ndf(directory.path(), &arguments); + let (second_status, second) = ndf(directory.path(), &arguments); + + assert_envelope(first_status, &first, "impact pto-release"); + assert_eq!(second_status, 0); + assert_eq!(first, second); + assert_eq!(first["data"]["schema_version"], "1"); + assert_eq!(first["data"]["compatibility"], "breaking"); +} + +#[test] +fn checked_in_cli_smoke_fixture_is_self_contained() { + let root = repository().join("tests/fixtures/cli-smoke/project"); + let root_argument = root.to_string_lossy(); + + let (status, payload) = ndf( + &root, + &["check", "--root", &root_argument, "--format", "json"], + ); + + assert_envelope(status, &payload, "check"); + assert_eq!(payload["data"]["node_count"], 2); +} diff --git a/crates/ndf-cli/tests/errors.rs b/crates/ndf-cli/tests/errors.rs index 57aaecf..424e1a0 100644 --- a/crates/ndf-cli/tests/errors.rs +++ b/crates/ndf-cli/tests/errors.rs @@ -69,3 +69,35 @@ fn fix_check_reports_reordering_without_writing() { assert_eq!(std::fs::read_to_string(&path).unwrap(), original); assert_eq!(Path::new(&result.changed[0]), path); } + +#[test] +fn pto_release_impact_rejects_missing_and_corrupt_indexes() { + let binary = assert_cmd::cargo::cargo_bin!("ndf"); + let directory = tempfile::tempdir().unwrap(); + let corrupt = directory.path().join("corrupt.sqlite"); + std::fs::write(&corrupt, "not a SQLite index").unwrap(); + + for before in [directory.path().join("missing.sqlite"), corrupt] { + let output = Command::new(binary) + .args([ + "impact", + "pto-release", + "--before", + before.to_str().unwrap(), + "--after", + before.to_str().unwrap(), + "--consumer-index", + before.to_str().unwrap(), + "--format", + "json", + ]) + .current_dir(directory.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["command"], "impact pto-release"); + assert_eq!(payload["ok"], false); + assert!(payload["diagnostics"][0]["message"].is_string()); + } +} diff --git a/crates/ndf-compiler/src/adapters/mod.rs b/crates/ndf-compiler/src/adapters/mod.rs index f8fbeb4..38368d6 100644 --- a/crates/ndf-compiler/src/adapters/mod.rs +++ b/crates/ndf-compiler/src/adapters/mod.rs @@ -1,8 +1,10 @@ +use std::collections::BTreeMap; use std::path::Path; use globset::{Glob, GlobSetBuilder}; use ndf_core::graph::GraphBuilder; -use ndf_core::model::{Graph, ParseResult}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Diagnostic, DiagnosticSeverity, Graph, ParseResult, SourceSpan}; use walkdir::WalkDir; use crate::CompilerError; @@ -97,6 +99,8 @@ pub fn parse_project(manifest: &ProjectManifest) -> Result paths.sort(); let mut graph = GraphBuilder::default(); + let mut instruction_identities: BTreeMap = + BTreeMap::new(); for path in paths { let adapter = adapter_for(&path).ok_or_else(|| { CompilerError::Contract(format!( @@ -106,6 +110,37 @@ pub fn parse_project(manifest: &ProjectManifest) -> Result })?; let parsed = adapter.parse(&path, manifest)?; for node in parsed.nodes { + if node.id.local_id().starts_with("PTO-INST-") + && let (Some(surface), Some(mnemonic)) = ( + node.attributes.get("surface"), + node.attributes.get("mnemonic"), + ) + { + if let Some((existing_surface, existing_mnemonic, existing_source)) = + instruction_identities.get(&node.id) + && (existing_surface != surface || existing_mnemonic != mnemonic) + { + graph.add_diagnostic(Diagnostic { + code: "NDF-ASL-005".to_owned(), + severity: DiagnosticSeverity::Error, + message: format!( + "conflicting PTO instruction identity {}: {existing_surface} {existing_mnemonic} and {surface} {mnemonic}", + node.id + ), + primary: node.source.clone(), + related: vec![existing_source.clone()], + repair: Some( + "Use distinct surface and mnemonic pairs that derive distinct stable identities." + .to_owned(), + ), + subject: Some(node.id.clone()), + }); + continue; + } + instruction_identities + .entry(node.id.clone()) + .or_insert_with(|| (surface.clone(), mnemonic.clone(), node.source.clone())); + } graph.add_node(node); } for edge in parsed.edges { diff --git a/crates/ndf-compiler/src/adapters/pto_asl.rs b/crates/ndf-compiler/src/adapters/pto_asl.rs index 86eb2af..50d1112 100644 --- a/crates/ndf-compiler/src/adapters/pto_asl.rs +++ b/crates/ndf-compiler/src/adapters/pto_asl.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::Path; @@ -6,7 +6,7 @@ use ndf_core::identity::NodeId; use ndf_core::model::{ Diagnostic, DiagnosticSeverity, Edge, LifecycleStatus, Node, NodeKind, ParseResult, SourceSpan, }; -use serde_json::Value; +use serde_json::{Map, Value}; use crate::CompilerError; use crate::manifest::ProjectManifest; @@ -293,13 +293,134 @@ fn instruction_node( status: LifecycleStatus::Active, owner: Some("pto-spec".to_owned()), body: String::new(), - attributes: BTreeMap::from([ - ("surface".to_owned(), surface.to_owned()), - ("mnemonic".to_owned(), mnemonic.to_owned()), - ]), + attributes: instruction_attributes(&metadata, surface, mnemonic), })) } +fn instruction_attributes( + metadata: &Map, + surface: &str, + mnemonic: &str, +) -> BTreeMap { + let semantic_handlers = string_field_values(metadata, &["semantic_handler"]); + let statuses = string_field_values(metadata, &["status", "contract_status"]); + let mut facets = BTreeSet::new(); + for (facet, fields) in [ + ( + "encoding", + &["encoding", "encoding_kind", "selector", "function"][..], + ), + ( + "instruction-semantics", + &["semantic_handler", "effect_contract", "semantic_summary"][..], + ), + ("legality", &["constraints", "legality_handler"][..]), + ("fault", &["fault_contract"][..]), + ( + "completion", + &["completion_contract", "restart_contract"][..], + ), + ("architectural-state", &["state_effects"][..]), + ( + "memory-ordering", + &[ + "memory_ordering", + "memory_ordering_contract", + "ordering_contract", + "memory_events", + ][..], + ), + ] { + if metadata_has_any(metadata, fields) { + facets.insert(facet); + } + } + + BTreeMap::from([ + ("surface".to_owned(), surface.to_owned()), + ("mnemonic".to_owned(), mnemonic.to_owned()), + ( + "semantic_handler".to_owned(), + joined_or_unspecified(semantic_handlers), + ), + ("status".to_owned(), joined_or_unspecified(statuses)), + ( + "semantic_facets".to_owned(), + facets.into_iter().collect::>().join(","), + ), + ]) +} + +fn joined_or_unspecified(values: BTreeSet) -> String { + if values.is_empty() { + "unspecified".to_owned() + } else { + values.into_iter().collect::>().join(",") + } +} + +fn string_field_values(metadata: &Map, fields: &[&str]) -> BTreeSet { + let mut values = BTreeSet::new(); + for (name, value) in metadata { + if fields.contains(&name.as_str()) + && let Some(value) = value.as_str() + && !value.is_empty() + { + values.insert(value.to_owned()); + } + collect_string_field_values(value, fields, &mut values); + } + values +} + +fn collect_string_field_values(value: &Value, fields: &[&str], values: &mut BTreeSet) { + match value { + Value::Object(object) => { + for (name, value) in object { + if fields.contains(&name.as_str()) + && let Some(value) = value.as_str() + && !value.is_empty() + { + values.insert(value.to_owned()); + } + collect_string_field_values(value, fields, values); + } + } + Value::Array(items) => { + for item in items { + collect_string_field_values(item, fields, values); + } + } + _ => {} + } +} + +fn metadata_has_any(metadata: &Map, fields: &[&str]) -> bool { + metadata.iter().any(|(name, value)| { + (fields.contains(&name.as_str()) && meaningful(value)) || value_has_any(value, fields) + }) +} + +fn value_has_any(value: &Value, fields: &[&str]) -> bool { + match value { + Value::Object(object) => object.iter().any(|(name, value)| { + (fields.contains(&name.as_str()) && meaningful(value)) || value_has_any(value, fields) + }), + Value::Array(items) => items.iter().any(|item| value_has_any(item, fields)), + _ => false, + } +} + +fn meaningful(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(value) => !value.is_empty(), + Value::Array(values) => !values.is_empty(), + Value::Object(values) => !values.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + fn parse_metadata( raw: &str, path: &str, diff --git a/crates/ndf-compiler/src/dependencies.rs b/crates/ndf-compiler/src/dependencies.rs index 1aea574..e019ff9 100644 --- a/crates/ndf-compiler/src/dependencies.rs +++ b/crates/ndf-compiler/src/dependencies.rs @@ -1,10 +1,13 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use ndf_core::model::{DiagnosticSeverity, Graph}; use serde::Serialize; use serde_yaml_ng::Value; use crate::CompilerError; +use crate::adapters::parse_project; use crate::manifest::{ProjectLock, ProjectManifest}; #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -15,6 +18,67 @@ pub struct ResolvedProject { pub dirty: bool, } +#[derive(Debug)] +pub struct DependencyGraph { + pub project_id: String, + pub revision: String, + pub graph: Graph, +} + +pub fn load_dependency_graphs( + manifest: &ProjectManifest, + lock: &ProjectLock, + workspace: &Path, +) -> Result, CompilerError> { + let resolved = resolve_dependencies(manifest, lock, workspace)?; + let mut graphs = BTreeMap::new(); + for dependency in resolved { + let declaration = &manifest.dependencies[&dependency.project_id]; + if !declaration.graph { + continue; + } + let manifest_path = dependency.path.join("ndf.yaml"); + if !manifest_path.is_file() { + return Err(contract(format!( + "NDF-DEP-005: graph dependency has no ndf.yaml: {}", + dependency.project_id + ))); + } + let dependency_manifest = ProjectManifest::load(&manifest_path).map_err(|error| { + contract(format!( + "NDF-DEP-005: invalid graph dependency manifest for {}: {error}", + dependency.project_id + )) + })?; + if dependency_manifest.project != dependency.project_id { + return Err(contract(format!( + "NDF-DEP-005: dependency project mismatch: expected {}, got {}", + dependency.project_id, dependency_manifest.project + ))); + } + let graph = parse_project(&dependency_manifest)?; + if let Some(diagnostic) = graph + .diagnostics + .iter() + .find(|item| item.severity == DiagnosticSeverity::Error) + { + return Err(contract(format!( + "NDF-DEP-006: dependency graph {} is invalid: {}: {}", + dependency.project_id, diagnostic.code, diagnostic.message + ))); + } + graphs.insert( + dependency.project_id.clone(), + DependencyGraph { + project_id: dependency.project_id, + revision: dependency.revision, + graph, + }, + ); + } + Ok(graphs) +} + pub fn resolve_dependencies( manifest: &ProjectManifest, lock: &ProjectLock, diff --git a/crates/ndf-compiler/src/index.rs b/crates/ndf-compiler/src/index.rs index 06de2d3..89e5abd 100644 --- a/crates/ndf-compiler/src/index.rs +++ b/crates/ndf-compiler/src/index.rs @@ -112,10 +112,10 @@ impl NdfIndex { .connection .prepare("SELECT * FROM nodes WHERE uri = ?")?; let mut rows = statement.query([uri])?; - rows.next()? - .map(node_record) - .transpose() - .map_err(Into::into) + let node = rows.next()?.map(node_record).transpose()?; + drop(rows); + drop(statement); + node.map(|node| self.with_node_attributes(node)).transpose() } pub fn all_nodes(&self) -> Result, CompilerError> { @@ -126,12 +126,8 @@ impl NdfIndex { &self, mut visitor: impl FnMut(NodeRecord) -> Result<(), CompilerError>, ) -> Result<(), CompilerError> { - let mut statement = self - .connection - .prepare("SELECT * FROM nodes ORDER BY uri")?; - let mut rows = statement.query([])?; - while let Some(row) = rows.next()? { - visitor(node_record(row)?)?; + for node in self.all_nodes()? { + visitor(node)?; } Ok(()) } @@ -155,9 +151,14 @@ impl NdfIndex { let sql = format!("SELECT * FROM nodes{where_clause} ORDER BY uri"); let mut statement = self.connection.prepare(&sql)?; let values = filters.iter().map(|(_, value)| *value); - let records = statement + let mut records = statement .query_map(params_from_iter(values), node_record)? .collect::, _>>()?; + drop(statement); + let mut attributes = self.all_node_attributes()?; + for node in &mut records { + node.attributes = attributes.remove(&node.id).unwrap_or_default(); + } Ok(records) } @@ -364,6 +365,33 @@ impl NdfIndex { .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?; Ok((nodes as usize, edges as usize)) } + + fn with_node_attributes(&self, mut node: NodeRecord) -> Result { + let mut statement = self + .connection + .prepare("SELECT key, value FROM node_attrs WHERE node_uri = ? ORDER BY key")?; + node.attributes = statement + .query_map([&node.id], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + Ok(node) + } + + fn all_node_attributes( + &self, + ) -> Result>, CompilerError> { + let mut statement = self + .connection + .prepare("SELECT node_uri, key, value FROM node_attrs ORDER BY node_uri, key")?; + let mut rows = statement.query([])?; + let mut attributes = BTreeMap::>::new(); + while let Some(row) = rows.next()? { + attributes + .entry(row.get(0)?) + .or_default() + .insert(row.get(1)?, row.get(2)?); + } + Ok(attributes) + } } fn canonical_uri(value: &str) -> Result { diff --git a/crates/ndf-compiler/src/lib.rs b/crates/ndf-compiler/src/lib.rs index 3b871a1..6d5c8ad 100644 --- a/crates/ndf-compiler/src/lib.rs +++ b/crates/ndf-compiler/src/lib.rs @@ -10,6 +10,7 @@ pub mod id_allocator; pub mod index; pub mod manifest; pub mod markdown; +pub mod pto_impact; pub mod query; pub mod report; pub mod rules; diff --git a/crates/ndf-compiler/src/manifest.rs b/crates/ndf-compiler/src/manifest.rs index 0d0a55d..65e28cf 100644 --- a/crates/ndf-compiler/src/manifest.rs +++ b/crates/ndf-compiler/src/manifest.rs @@ -9,6 +9,7 @@ use crate::CompilerError; #[derive(Debug, Clone, Eq, PartialEq)] pub struct DependencyDeclaration { pub path: String, + pub graph: bool, } #[derive(Debug, Clone, PartialEq)] @@ -65,16 +66,12 @@ impl ProjectManifest { let declaration = value .as_mapping() .ok_or_else(|| contract("each dependency declaration must be a named mapping"))?; - reject_unknown(declaration, &["path"], "dependency declaration")?; - if declaration.len() != 1 { - return Err(contract( - "dependency declaration must contain only a string path", - )); - } + reject_unknown(declaration, &["path", "graph"], "dependency declaration")?; dependencies.insert( name, DependencyDeclaration { path: required_string(declaration, "path")?, + graph: optional_bool(declaration, "graph")?, }, ); } @@ -200,6 +197,14 @@ fn optional_strings(mapping: &Mapping, name: &str) -> Result, Compil .map_or_else(|| Ok(Vec::new()), |value| string_list(value, name)) } +fn optional_bool(mapping: &Mapping, name: &str) -> Result { + mapping.get(key(name)).map_or(Ok(false), |value| { + value + .as_bool() + .ok_or_else(|| contract(format!("{name} must be a boolean"))) + }) +} + fn string_list(value: &Value, name: &str) -> Result, CompilerError> { let items = value .as_sequence() diff --git a/crates/ndf-compiler/src/markdown.rs b/crates/ndf-compiler/src/markdown.rs index 3cc3ada..31592b6 100644 --- a/crates/ndf-compiler/src/markdown.rs +++ b/crates/ndf-compiler/src/markdown.rs @@ -27,6 +27,8 @@ const EDGE_FIELDS: &[&str] = &[ "evidenced-by", "couples-with", "blocks-by", + "conforms-to", + "tracks", ]; const CANONICAL_FIELDS: &[&str] = &[ "kind", @@ -47,6 +49,7 @@ const ATTRIBUTE_FIELDS: &[&str] = &[ "origin-status", "model", "superseded-by", + "expected", ]; pub fn parse_markdown( @@ -173,6 +176,14 @@ pub fn parse_markdown( if let Some(migration) = migration { builder.add_diagnostic(migration); } + if let Some(item) = cross_project_metadata_diagnostic( + &metadata, + &relative_path, + (metadata_index + 1) as u32, + ) { + builder.add_diagnostic(item); + continue; + } let Some(kind) = node_kind(metadata.get("kind").map_or("information", String::as_str)) else { builder.add_diagnostic(invalid_value(&relative_path, metadata_index + 1, "kind")); @@ -266,6 +277,35 @@ pub fn parse_markdown( }) } +fn cross_project_metadata_diagnostic( + metadata: &BTreeMap, + path: &str, + line: u32, +) -> Option { + match ( + metadata.get("tracks").map(String::as_str), + metadata.get("expected").map(String::as_str), + ) { + (None, None) | (Some(_), Some("absent")) => None, + (None, Some(_)) => Some(diagnostic( + "NDF-XREF-001", + DiagnosticSeverity::Error, + "expected is valid only with a tracks relationship", + path, + line, + "Add tracks= or remove expected.", + )), + (Some(_), _) => Some(diagnostic( + "NDF-XREF-001", + DiagnosticSeverity::Error, + "tracks requires expected=absent", + path, + line, + "Set expected=absent for the tracked external identity.", + )), + } +} + struct Heading<'a> { title: String, local_id: Option<&'a str>, diff --git a/crates/ndf-compiler/src/pto_impact.rs b/crates/ndf-compiler/src/pto_impact.rs new file mode 100644 index 0000000..313bb81 --- /dev/null +++ b/crates/ndf-compiler/src/pto_impact.rs @@ -0,0 +1,364 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use serde::Serialize; + +use crate::CompilerError; +use crate::index::{NdfIndex, NodeRecord}; + +const PROPAGATION_EDGES: &[&str] = &[ + "affects", + "blocks", + "depends-on", + "implements", + "references", + "refines", + "verifies", +]; + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Compatibility { + Compatible, + ReviewRequired, + Breaking, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PtoChangeKind { + Added, + Removed, + Modified, + Moved, + Superseded, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct PtoChange { + pub uri: String, + pub kind: PtoChangeKind, + pub facets: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct ConsumerImpact { + pub uri: String, + pub direct: bool, + pub reasons: Vec, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct ConsumerConformance { + pub consumer_uri: String, + pub target_uri: String, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct ConsumerWatch { + pub consumer_uri: String, + pub target_uri: String, + pub expected: String, + pub present: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct PtoReleaseImpact { + pub schema_version: String, + pub compatibility: Compatibility, + pub conformance_targets: Vec, + pub absence_watches: Vec, + pub changes: Vec, + pub affected_consumers: Vec, + pub presence_changes: Vec, + pub deterministic_actions: Vec, + pub human_decisions: Vec, +} + +pub fn pto_release_impact( + before: &NdfIndex, + after: &NdfIndex, + consumer: &NdfIndex, +) -> Result { + let before_nodes = nodes_by_uri(before)?; + let after_nodes = nodes_by_uri(after)?; + let consumer_nodes = nodes_by_uri(consumer)?; + let before_uris: BTreeSet<_> = before_nodes.keys().cloned().collect(); + let after_uris: BTreeSet<_> = after_nodes.keys().cloned().collect(); + let superseded_targets: BTreeSet<_> = after + .all_edges()? + .into_iter() + .filter(|edge| edge.edge_type == "supersedes") + .map(|edge| edge.target) + .collect(); + + let mut changes = Vec::new(); + for uri in after_uris.difference(&before_uris) { + changes.push(PtoChange { + uri: uri.clone(), + kind: PtoChangeKind::Added, + facets: node_facets(&after_nodes[uri]), + }); + } + for uri in before_uris.difference(&after_uris) { + changes.push(PtoChange { + uri: uri.clone(), + kind: PtoChangeKind::Removed, + facets: node_facets(&before_nodes[uri]), + }); + } + for uri in before_uris.intersection(&after_uris) { + let old = &before_nodes[uri]; + let new = &after_nodes[uri]; + let kind = if (old.status != "superseded" && new.status == "superseded") + || superseded_targets.contains(uri) + { + Some(PtoChangeKind::Superseded) + } else if !semantically_equal(old, new) { + Some(PtoChangeKind::Modified) + } else if old.source != new.source { + Some(PtoChangeKind::Moved) + } else { + None + }; + if let Some(kind) = kind { + changes.push(PtoChange { + uri: uri.clone(), + kind, + facets: if kind == PtoChangeKind::Modified { + modified_facets(old, new) + } else { + merged_facets(old, new) + }, + }); + } + } + changes.sort_by(|left, right| (&left.uri, left.kind).cmp(&(&right.uri, right.kind))); + + let consumer_edges = consumer.all_edges()?; + let mut conformance_targets: Vec<_> = consumer_edges + .iter() + .filter(|edge| edge.edge_type == "conforms-to") + .map(|edge| ConsumerConformance { + consumer_uri: edge.source.clone(), + target_uri: edge.target.clone(), + }) + .collect(); + conformance_targets.sort(); + let mut absence_watches: Vec<_> = consumer_edges + .iter() + .filter(|edge| edge.edge_type == "tracks") + .map(|edge| ConsumerWatch { + consumer_uri: edge.source.clone(), + target_uri: edge.target.clone(), + expected: consumer_nodes + .get(&edge.source) + .and_then(|node| node.attributes.get("expected")) + .cloned() + .unwrap_or_else(|| "unspecified".to_owned()), + present: after_uris.contains(&edge.target), + }) + .collect(); + absence_watches.sort(); + + let changes_by_uri: BTreeMap<_, Vec<_>> = changes.iter().fold( + BTreeMap::<&str, Vec<&PtoChange>>::new(), + |mut result, change| { + result.entry(&change.uri).or_default().push(change); + result + }, + ); + let mut compatibility = Compatibility::Compatible; + let mut direct_reasons: BTreeMap> = BTreeMap::new(); + for conformance in &conformance_targets { + for change in changes_by_uri + .get(conformance.target_uri.as_str()) + .into_iter() + .flatten() + { + let required = match change.kind { + PtoChangeKind::Removed | PtoChangeKind::Modified => Compatibility::Breaking, + PtoChangeKind::Moved | PtoChangeKind::Superseded | PtoChangeKind::Added => { + Compatibility::ReviewRequired + } + }; + compatibility = compatibility.max(required); + direct_reasons + .entry(conformance.consumer_uri.clone()) + .or_default() + .insert(format!( + "{} conformance target: {}", + change_kind_label(change.kind), + change.uri + )); + } + } + + let mut presence_changes = BTreeSet::new(); + for watch in &absence_watches { + if watch.expected == "absent" && !before_uris.contains(&watch.target_uri) && watch.present { + compatibility = compatibility.max(Compatibility::ReviewRequired); + presence_changes.insert(watch.target_uri.clone()); + direct_reasons + .entry(watch.consumer_uri.clone()) + .or_default() + .insert(format!( + "expected-absent target became present: {}", + watch.target_uri + )); + } + } + + let affected_consumers = + propagate_consumer_impact(&consumer_nodes, &consumer_edges, &direct_reasons); + let (deterministic_actions, human_decisions) = actions(compatibility); + + Ok(PtoReleaseImpact { + schema_version: "1".to_owned(), + compatibility, + conformance_targets, + absence_watches, + changes, + affected_consumers, + presence_changes: presence_changes.into_iter().collect(), + deterministic_actions, + human_decisions, + }) +} + +fn nodes_by_uri(index: &NdfIndex) -> Result, CompilerError> { + Ok(index + .all_nodes()? + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect()) +} + +fn semantically_equal(left: &NodeRecord, right: &NodeRecord) -> bool { + left.kind == right.kind + && left.title == right.title + && left.modality == right.modality + && left.refinement == right.refinement + && left.domain == right.domain + && left.status == right.status + && left.owner == right.owner + && left.body == right.body + && left.attributes == right.attributes +} + +fn node_facets(node: &NodeRecord) -> Vec { + node.attributes + .get("semantic_facets") + .into_iter() + .flat_map(|value| value.split(',')) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect::>() + .into_iter() + .collect() +} + +fn merged_facets(left: &NodeRecord, right: &NodeRecord) -> Vec { + node_facets(left) + .into_iter() + .chain(node_facets(right)) + .collect::>() + .into_iter() + .collect() +} + +fn modified_facets(left: &NodeRecord, right: &NodeRecord) -> Vec { + let mut facets = BTreeSet::new(); + if left.attributes.get("semantic_handler") != right.attributes.get("semantic_handler") { + facets.insert("instruction-semantics".to_owned()); + } + if left.attributes.get("semantic_facets") != right.attributes.get("semantic_facets") { + facets.extend(merged_facets(left, right)); + } + if facets.is_empty() { + facets.extend(merged_facets(left, right)); + } + if facets.is_empty() { + facets.insert("contract".to_owned()); + } + facets.into_iter().collect() +} + +fn propagate_consumer_impact( + consumer_nodes: &BTreeMap, + consumer_edges: &[crate::index::IndexedEdge], + direct_reasons: &BTreeMap>, +) -> Vec { + let mut adjacency: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for edge in consumer_edges { + if PROPAGATION_EDGES.contains(&edge.edge_type.as_str()) + && consumer_nodes.contains_key(&edge.source) + && consumer_nodes.contains_key(&edge.target) + { + adjacency + .entry(&edge.source) + .or_default() + .insert(&edge.target); + adjacency + .entry(&edge.target) + .or_default() + .insert(&edge.source); + } + } + + let mut affected: BTreeMap> = BTreeMap::new(); + for (direct, reasons) in direct_reasons { + let mut queue = VecDeque::from([direct.as_str()]); + let mut visited = BTreeSet::new(); + while let Some(uri) = queue.pop_front() { + if !visited.insert(uri) { + continue; + } + affected + .entry(uri.to_owned()) + .or_default() + .extend(reasons.iter().cloned()); + if let Some(neighbors) = adjacency.get(uri) { + queue.extend(neighbors.iter().copied()); + } + } + } + + affected + .into_iter() + .map(|(uri, reasons)| ConsumerImpact { + direct: direct_reasons.contains_key(&uri), + uri, + reasons: reasons.into_iter().collect(), + }) + .collect() +} + +fn change_kind_label(kind: PtoChangeKind) -> &'static str { + match kind { + PtoChangeKind::Added => "added", + PtoChangeKind::Removed => "removed", + PtoChangeKind::Modified => "modified", + PtoChangeKind::Moved => "moved", + PtoChangeKind::Superseded => "superseded", + } +} + +fn actions(compatibility: Compatibility) -> (Vec, Vec) { + match compatibility { + Compatibility::Compatible => ( + vec!["record compatible PTO release impact".to_owned()], + Vec::new(), + ), + Compatibility::ReviewRequired => ( + vec!["hold automatic PTO release adoption pending review".to_owned()], + vec!["review moved, superseded, or newly present PTO contracts".to_owned()], + ), + Compatibility::Breaking => ( + vec![ + "block automatic PTO release adoption".to_owned(), + "rebuild and validate affected consumer closure".to_owned(), + ], + vec!["approve each consumer conformance migration or waiver".to_owned()], + ), + } +} diff --git a/crates/ndf-compiler/src/rules.rs b/crates/ndf-compiler/src/rules.rs index 90c6430..a6d7765 100644 --- a/crates/ndf-compiler/src/rules.rs +++ b/crates/ndf-compiler/src/rules.rs @@ -1,10 +1,13 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use ndf_core::identity::NodeId; use ndf_core::model::{ - Diagnostic, DiagnosticSeverity, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan, + Diagnostic, DiagnosticSeverity, Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, + SourceSpan, }; +use crate::dependencies::DependencyGraph; + #[derive(Debug, Clone, Eq, PartialEq)] pub struct ValidationPolicy { pub mandatory_edge_types: BTreeSet<&'static str>, @@ -124,6 +127,134 @@ pub fn validate_graph(graph: &Graph, policy: &ValidationPolicy) -> Vec, + policy: &ValidationPolicy, +) -> Vec { + let mut diagnostics = validate_graph(graph, policy); + let nodes_by_id: HashMap<&NodeId, &Node> = + graph.nodes.iter().map(|node| (&node.id, node)).collect(); + let dependency_ids: BTreeMap<_, BTreeSet<_>> = dependencies + .iter() + .map(|(project, dependency)| { + ( + project.as_str(), + dependency.graph.nodes.iter().map(|node| &node.id).collect(), + ) + }) + .collect(); + + for edge in &graph.edges { + if !matches!(edge.edge_type.as_str(), "conforms-to" | "tracks") { + continue; + } + let source = nodes_by_id.get(&edge.source).copied(); + if edge.edge_type == "tracks" + && source.and_then(|node| node.attributes.get("expected").map(String::as_str)) + != Some("absent") + { + diagnostics.push(edge_diagnostic( + "NDF-XREF-001", + "tracks requires expected=absent on its source node", + edge, + source, + "Set expected=absent or remove the tracks relationship.", + )); + continue; + } + if source.is_some_and(|node| node.id.project_id() == edge.target.project_id()) { + let (code, message, repair) = if edge.edge_type == "tracks" { + ( + "NDF-XREF-001", + "expected-absent tracks relationship must target an external project", + "Use a full identity in a declared graph dependency.", + ) + } else { + ( + "NDF-XREF-003", + "conforms-to relationship must target a declared external project", + "Use a target from a declared graph dependency.", + ) + }; + diagnostics.push(edge_diagnostic(code, message, edge, source, repair)); + continue; + } + let Some(ids) = dependency_ids.get(edge.target.project_id()) else { + diagnostics.push(edge_diagnostic( + "NDF-XREF-003", + format!( + "external target project is not a declared graph dependency: {}", + edge.target.project_id() + ), + edge, + source, + "Declare and exactly lock the external graph dependency.", + )); + continue; + }; + let present = ids.contains(&edge.target); + if edge.edge_type == "conforms-to" && !present { + diagnostics.push(edge_diagnostic( + "NDF-XREF-002", + format!("conforms-to target does not resolve: {}", edge.target), + edge, + source, + "Use a stable identity present in the exact dependency graph.", + )); + } else if edge.edge_type == "tracks" && present { + diagnostics.push(edge_diagnostic( + "NDF-XREF-004", + format!( + "expected-absent tracked identity is now present: {}", + edge.target + ), + edge, + source, + "Review the upstream contract before changing the consumer clause.", + )); + } + } + + diagnostics.sort_by(|left, right| { + ( + &left.primary.path, + left.primary.line, + &left.code, + left.subject.as_ref(), + ) + .cmp(&( + &right.primary.path, + right.primary.line, + &right.code, + right.subject.as_ref(), + )) + }); + diagnostics +} + +fn edge_diagnostic( + code: &str, + message: impl Into, + edge: &Edge, + source: Option<&Node>, + repair: &str, +) -> Diagnostic { + Diagnostic { + code: code.to_owned(), + severity: DiagnosticSeverity::Error, + message: message.into(), + primary: edge + .source_span + .clone() + .or_else(|| source.map(|node| node.source.clone())) + .unwrap_or_else(|| SourceSpan::new("", 1).expect("line one is valid")), + related: Vec::new(), + repair: Some(repair.to_owned()), + subject: Some(edge.source.clone()), + } +} + fn node_diagnostic(code: &str, message: String, node: &Node, repair: &str) -> Diagnostic { Diagnostic { code: code.to_owned(), diff --git a/crates/ndf-compiler/tests/dependencies.rs b/crates/ndf-compiler/tests/dependencies.rs index f7c83cf..8d9d6e0 100644 --- a/crates/ndf-compiler/tests/dependencies.rs +++ b/crates/ndf-compiler/tests/dependencies.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::Path; use std::process::Command; -use ndf_compiler::dependencies::resolve_dependencies; +use ndf_compiler::dependencies::{load_dependency_graphs, resolve_dependencies}; use ndf_compiler::manifest::{ DependencyDeclaration, LockedDependency, ProjectLock, ProjectManifest, }; @@ -35,6 +35,7 @@ fn contracts(root: &Path, revision: &str) -> (ProjectManifest, ProjectLock) { "tool".to_owned(), DependencyDeclaration { path: "deps/tool".to_owned(), + graph: false, }, )]); let lock = ProjectLock { @@ -63,9 +64,141 @@ fn exact_clean_git_revision_is_resolved_without_repository_mutation() { assert_eq!(resolved[0].project_id, "tool"); assert_eq!(resolved[0].revision, revision); assert!(!resolved[0].dirty); + assert!( + load_dependency_graphs(&manifest, &lock, directory.path()) + .unwrap() + .is_empty() + ); + assert_eq!(git(&dependency, &["status", "--porcelain=v1"]), before); +} + +fn graph_contracts(root: &Path, project: &str, revision: &str) -> (ProjectManifest, ProjectLock) { + let mut manifest = ProjectManifest::for_test("consumer", root, ["REQ"], []); + manifest.dependencies = BTreeMap::from([( + project.to_owned(), + DependencyDeclaration { + path: format!("deps/{project}"), + graph: true, + }, + )]); + let lock = ProjectLock { + format_version: "0.1".to_owned(), + dependencies: vec![LockedDependency { + project: project.to_owned(), + uri: format!("https://example.invalid/{project}.git"), + revision: revision.to_owned(), + path: format!("deps/{project}"), + }], + }; + (manifest, lock) +} + +fn commit_graph_project(path: &Path, project: &str, asl: &str) -> String { + fs::create_dir_all(path.join("asl")).unwrap(); + git(path, &["init", "-q"]); + git(path, &["config", "user.email", "ndf@example.invalid"]); + git(path, &["config", "user.name", "NDF Test"]); + fs::write( + path.join("ndf.yaml"), + format!( + "format_version: '0.2'\nproject: {project}\nroots: ['asl/**/*.asl']\nid_prefixes: [PTO]\ndomains: [architecture]\npolicies: {{}}\ndependencies: {{}}\n" + ), + ) + .unwrap(); + fs::write(path.join("asl/contracts.asl"), asl).unwrap(); + git(path, &["add", "ndf.yaml", "asl/contracts.asl"]); + git(path, &["commit", "-qm", "graph fixture"]); + git(path, &["rev-parse", "HEAD"]) +} + +#[test] +fn resolved_dependency_graphs_are_exact_read_only_namespaces() { + let directory = tempfile::tempdir().unwrap(); + let dependency = directory.path().join("deps/pto-spec"); + let revision = commit_graph_project( + &dependency, + "pto-spec", + r#"// NDF-BEGIN: PTO-CONTRACT-001 +// ndf: kind=contract level=L1 layer=architecture status=accepted +// The released architecture contract is authoritative. +// NDF-END: PTO-CONTRACT-001 +"#, + ); + let before = git(&dependency, &["status", "--porcelain=v1"]); + let (manifest, lock) = graph_contracts(directory.path(), "pto-spec", &revision); + + let graphs = load_dependency_graphs(&manifest, &lock, directory.path()).unwrap(); + + assert_eq!(graphs.len(), 1); + let graph = &graphs["pto-spec"]; + assert_eq!(graph.project_id, "pto-spec"); + assert_eq!(graph.revision, revision); + assert!( + graph + .graph + .nodes + .iter() + .any(|node| { node.id.to_string() == "ndf://pto-spec/PTO-CONTRACT-001" }) + ); assert_eq!(git(&dependency, &["status", "--porcelain=v1"]), before); } +#[test] +fn graph_dependencies_reject_missing_mismatched_and_duplicate_projects() { + let missing = tempfile::tempdir().unwrap(); + let missing_dependency = missing.path().join("deps/pto-spec"); + let revision = git_repository(&missing_dependency); + let (manifest, lock) = graph_contracts(missing.path(), "pto-spec", &revision); + assert!( + load_dependency_graphs(&manifest, &lock, missing.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-005") + ); + + let mismatched = tempfile::tempdir().unwrap(); + let mismatched_dependency = mismatched.path().join("deps/pto-spec"); + let revision = commit_graph_project( + &mismatched_dependency, + "another-project", + r#"// NDF-BEGIN: PTO-CONTRACT-001 +// ndf: kind=contract level=L1 layer=architecture status=accepted +// The released architecture contract is authoritative. +// NDF-END: PTO-CONTRACT-001 +"#, + ); + let (manifest, lock) = graph_contracts(mismatched.path(), "pto-spec", &revision); + assert!( + load_dependency_graphs(&manifest, &lock, mismatched.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-005") + ); + + let duplicate = tempfile::tempdir().unwrap(); + let duplicate_dependency = duplicate.path().join("deps/pto-spec"); + let revision = commit_graph_project( + &duplicate_dependency, + "pto-spec", + r#"// NDF-BEGIN: PTO-CONTRACT-001 +// ndf: kind=contract level=L1 layer=architecture status=accepted +// The first contract is authoritative. +// NDF-END: PTO-CONTRACT-001 +// NDF-BEGIN: PTO-CONTRACT-001 +// ndf: kind=contract level=L1 layer=architecture status=accepted +// The duplicate contract is invalid. +// NDF-END: PTO-CONTRACT-001 +"#, + ); + let (manifest, lock) = graph_contracts(duplicate.path(), "pto-spec", &revision); + assert!( + load_dependency_graphs(&manifest, &lock, duplicate.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-006") + ); +} + #[test] fn missing_symbolic_mismatched_and_dirty_dependencies_are_rejected() { let missing = tempfile::tempdir().unwrap(); diff --git a/crates/ndf-compiler/tests/manifest.rs b/crates/ndf-compiler/tests/manifest.rs index 02479bf..08ca851 100644 --- a/crates/ndf-compiler/tests/manifest.rs +++ b/crates/ndf-compiler/tests/manifest.rs @@ -53,3 +53,56 @@ fn manifest_reports_the_first_missing_required_field() { assert!(error.to_string().contains("missing required field: roots")); } + +#[test] +fn dependency_graph_loading_is_explicit_and_defaults_to_tool_only() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ndf.yaml"); + fs::write( + &path, + r#"format_version: '0.2' +project: consumer +roots: [] +id_prefixes: [REQ] +domains: [] +policies: {} +dependencies: + ndf: + path: toolchains/ndf + pto-spec: + path: isa + graph: true +"#, + ) + .unwrap(); + + let manifest = ProjectManifest::load(&path).unwrap(); + + assert!(!manifest.dependencies["ndf"].graph); + assert!(manifest.dependencies["pto-spec"].graph); +} + +#[test] +fn dependency_graph_flag_must_be_boolean() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ndf.yaml"); + fs::write( + &path, + r#"format_version: '0.2' +project: consumer +roots: [] +id_prefixes: [REQ] +domains: [] +policies: {} +dependencies: + pto-spec: + path: isa + graph: yes +"#, + ) + .unwrap(); + + let error = ProjectManifest::load(&path).unwrap_err(); + + assert!(error.to_string().contains("graph must be a boolean")); +} diff --git a/crates/ndf-compiler/tests/markdown.rs b/crates/ndf-compiler/tests/markdown.rs index b392480..634a88f 100644 --- a/crates/ndf-compiler/tests/markdown.rs +++ b/crates/ndf-compiler/tests/markdown.rs @@ -22,6 +22,15 @@ fn graph_value(path: &Path, manifest: &ProjectManifest) -> Value { .unwrap() } +fn parse_fixture(text: &str) -> ndf_core::model::ParseResult { + let directory = tempdir().unwrap(); + let path = directory.path().join("contracts.md"); + fs::write(&path, text).unwrap(); + let manifest = + ProjectManifest::for_test("davincioo", directory.path(), ["DOC", "ISA"], ["isa"]); + parse_markdown(&path, &manifest).unwrap() +} + #[test] fn valid_markdown_matches_the_frozen_contract() { let root = repository().join("tests/fixtures/markdown/project"); @@ -155,3 +164,91 @@ This contract MUST remain traceable after supersession. 4 ); } + +#[test] +fn cross_project_relationships_preserve_edges_and_absence_expectation() { + let parsed = parse_fixture( + r#"--- +doc_id: DOC-CROSS-PROJECT +status: active +authority: normative +owner: architecture +--- + +# Cross-project contracts + +## Accepted load contract {#ISA-TLOAD-001} + + +DavinciOO MUST conform to the accepted load contract. + +## Prospective extension {#ISA-PEXT-001} + + +The prospective operation remains disabled. +"#, + ); + + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + assert!(parsed.edges.iter().any(|edge| { + edge.edge_type == "conforms-to" + && edge.source.to_string() == "ndf://davincioo/ISA-TLOAD-001" + && edge.target.to_string() == "ndf://pto-spec/PTO-INST-TILE-TLOAD" + })); + assert!(parsed.edges.iter().any(|edge| { + edge.edge_type == "tracks" + && edge.source.to_string() == "ndf://davincioo/ISA-PEXT-001" + && edge.target.to_string() == "ndf://pto-spec/PTO-INST-TILE-PEXT" + })); + let watch = parsed + .nodes + .iter() + .find(|node| node.id.to_string() == "ndf://davincioo/ISA-PEXT-001") + .unwrap(); + assert_eq!( + watch.attributes.get("expected").map(String::as_str), + Some("absent") + ); +} + +#[test] +fn expected_is_restricted_to_tracks_and_absent() { + let parsed = parse_fixture( + r#"--- +doc_id: DOC-CROSS-PROJECT-INVALID +status: active +authority: normative +owner: architecture +--- + +# Invalid cross-project contracts + +## Unsupported expectation {#ISA-WATCH-001} + + +The expectation is invalid. + +## Missing watch {#ISA-WATCH-002} + + +The expectation has no watched identity. + +## Missing expectation {#ISA-WATCH-003} + + +The watched identity has no absence expectation. +"#, + ); + + let diagnostics: Vec<_> = parsed + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "NDF-XREF-001") + .collect(); + assert_eq!(diagnostics.len(), 3, "{:#?}", parsed.diagnostics); + assert!( + diagnostics.iter().all(|diagnostic| { + diagnostic.severity == ndf_core::model::DiagnosticSeverity::Error + }) + ); +} diff --git a/crates/ndf-compiler/tests/pto_asl.rs b/crates/ndf-compiler/tests/pto_asl.rs index eb0c406..d0cc7ea 100644 --- a/crates/ndf-compiler/tests/pto_asl.rs +++ b/crates/ndf-compiler/tests/pto_asl.rs @@ -2,7 +2,8 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use ndf_compiler::adapters::pto_asl::{instruction_clause_id, parse_pto_asl}; +use ndf_compiler::adapters::parse_project; +use ndf_compiler::adapters::pto_asl::{instruction_clause_id, parse_pto_asl, parse_pto_asl_text}; use ndf_compiler::manifest::ProjectManifest; use ndf_core::model::{Graph, LifecycleStatus, NodeKind}; use serde_json::Value; @@ -67,3 +68,63 @@ fn instruction_identity_and_clause_contracts_are_preserved() { assert_eq!(parsed.nodes[1].status, LifecycleStatus::Active); assert_eq!(parsed.edges[0].edge_type, "references"); } + +#[test] +fn instruction_identity_ignores_source_path() { + let directory = tempfile::tempdir().unwrap(); + let project = ProjectManifest::for_test("pto-spec", directory.path(), ["PTO"], ["tile"]); + let text = concat!( + "// PTO-INSTRUCTION: ", + r#"{"surface":"tile","mnemonic":"TLOAD","catalog_records":[{"semantic_handler":"TLOAD","contract_status":"reviewed-complete"}]}"#, + "\n", + ); + + let original = + parse_pto_asl_text(text, &directory.path().join("original/TLOAD.asl"), &project).unwrap(); + let moved = + parse_pto_asl_text(text, &directory.path().join("moved/TLOAD.asl"), &project).unwrap(); + + assert_eq!(original.nodes[0].id, moved.nodes[0].id); + assert_eq!(original.nodes[0].attributes, moved.nodes[0].attributes); + assert_ne!(original.nodes[0].source.path, moved.nodes[0].source.path); +} + +#[test] +fn instruction_facets_are_indexed() { + let root = repository().join("tests/fixtures/pto-asl"); + let project = ProjectManifest::for_test("pto-spec", &root, ["PTO"], ["tile"]); + let parsed = parse_pto_asl(&root.join("accept.asl"), &project).unwrap(); + let instruction = &parsed.nodes[0]; + + assert_eq!(instruction.attributes["surface"], "tile"); + assert_eq!(instruction.attributes["mnemonic"], "TLOAD"); + assert_eq!(instruction.attributes["semantic_handler"], "TLOAD"); + assert_eq!(instruction.attributes["status"], "reviewed-complete"); + assert_eq!( + instruction.attributes["semantic_facets"], + "architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering" + ); +} + +#[test] +fn colliding_instruction_identities_report_asl_005() { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("first.asl"), + "// PTO-INSTRUCTION: {\"surface\":\"tile\",\"mnemonic\":\"T.LOAD\"}\n", + ) + .unwrap(); + fs::write( + directory.path().join("second.asl"), + "// PTO-INSTRUCTION: {\"surface\":\"tile\",\"mnemonic\":\"T-LOAD\"}\n", + ) + .unwrap(); + let mut project = ProjectManifest::for_test("pto-spec", directory.path(), ["PTO"], ["tile"]); + project.roots = vec!["*.asl".to_owned()]; + + let graph = parse_project(&project).unwrap(); + + assert_eq!(graph.diagnostics.len(), 1); + assert_eq!(graph.diagnostics[0].code, "NDF-ASL-005"); + assert_eq!(graph.diagnostics[0].related.len(), 1); +} diff --git a/crates/ndf-compiler/tests/pto_impact.rs b/crates/ndf-compiler/tests/pto_impact.rs new file mode 100644 index 0000000..ca3c2f7 --- /dev/null +++ b/crates/ndf-compiler/tests/pto_impact.rs @@ -0,0 +1,175 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_compiler::pto_impact::{Compatibility, PtoChangeKind, pto_release_impact}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; +use serde_json::Value; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn fixture(name: &str) -> Value { + serde_json::from_str( + &fs::read_to_string(repository().join("tests/fixtures/pto-impact").join(name)).unwrap(), + ) + .unwrap() +} + +fn graph(value: &Value) -> Graph { + let nodes = value["nodes"] + .as_array() + .unwrap() + .iter() + .map(|value| { + let id = value["id"].as_str().unwrap(); + let mut attributes = BTreeMap::new(); + for name in ["semantic_handler", "semantic_facets", "expected"] { + if let Some(value) = value.get(name).and_then(Value::as_str) { + attributes.insert(name.to_owned(), value.to_owned()); + } + } + Node { + id: NodeId::parse(id, None).unwrap(), + kind: NodeKind::Architecture, + title: value + .get("title") + .and_then(Value::as_str) + .unwrap_or(id) + .to_owned(), + source: SourceSpan::new(value["source"].as_str().unwrap(), 1).unwrap(), + modality: None, + refinement: None, + domain: None, + status: match value.get("status").and_then(Value::as_str) { + Some("superseded") => LifecycleStatus::Superseded, + _ => LifecycleStatus::Active, + }, + owner: None, + body: value + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + attributes, + } + }) + .collect(); + let edges = value["edges"] + .as_array() + .unwrap() + .iter() + .map(|value| { + Edge::new( + NodeId::parse(value["source"].as_str().unwrap(), None).unwrap(), + NodeId::parse(value["target"].as_str().unwrap(), None).unwrap(), + value["type"].as_str().unwrap(), + None, + ) + .unwrap() + }) + .collect(); + Graph { + nodes, + edges, + diagnostics: Vec::new(), + } +} + +fn provenance(project: &str) -> BuildProvenance { + BuildProvenance { + project_commits: vec![(project.to_owned(), "0".repeat(40))], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + } +} + +fn index(directory: &Path, name: &str, project: &str) -> PathBuf { + let output = directory.join(format!("{name}.sqlite")); + build_index(&graph(&fixture(name)), &output, &provenance(project)).unwrap(); + output +} + +fn golden(name: &str) -> Value { + serde_json::from_str(&fs::read_to_string(repository().join("tests/golden").join(name)).unwrap()) + .unwrap() +} + +#[test] +fn unrelated_addition_is_compatible_and_deterministic() { + let directory = tempfile::tempdir().unwrap(); + let before_path = index(directory.path(), "before.ndf.json", "pto-spec"); + let after_path = index(directory.path(), "after-compatible.ndf.json", "pto-spec"); + let consumer_path = index(directory.path(), "consumer.ndf.json", "davincioo"); + let before = NdfIndex::open(&before_path).unwrap(); + let after = NdfIndex::open(&after_path).unwrap(); + let consumer = NdfIndex::open(&consumer_path).unwrap(); + + let first = pto_release_impact(&before, &after, &consumer).unwrap(); + let second = pto_release_impact(&before, &after, &consumer).unwrap(); + + assert_eq!(first.compatibility, Compatibility::Compatible); + assert_eq!( + serde_json::to_value(&first).unwrap(), + golden("pto-impact-compatible.json") + ); + assert_eq!( + serde_json::to_vec_pretty(&first).unwrap(), + serde_json::to_vec_pretty(&second).unwrap() + ); +} + +#[test] +fn release_changes_classify_breakage_review_and_transitive_consumers() { + let directory = tempfile::tempdir().unwrap(); + let before_path = index(directory.path(), "before.ndf.json", "pto-spec"); + let after_path = index(directory.path(), "after-breaking.ndf.json", "pto-spec"); + let consumer_path = index(directory.path(), "consumer.ndf.json", "davincioo"); + let before = NdfIndex::open(&before_path).unwrap(); + let after = NdfIndex::open(&after_path).unwrap(); + let consumer = NdfIndex::open(&consumer_path).unwrap(); + + let impact = pto_release_impact(&before, &after, &consumer).unwrap(); + + assert_eq!(impact.compatibility, Compatibility::Breaking); + assert!(impact.changes.iter().any(|change| { + change.uri == "ndf://pto-spec/PTO-INST-TILE-TLOAD" + && change.kind == PtoChangeKind::Modified + && change.facets == ["instruction-semantics"] + })); + assert!(impact.changes.iter().any(|change| { + change.uri == "ndf://pto-spec/PTO-INST-TILE-TSTORE" && change.kind == PtoChangeKind::Removed + })); + assert!(impact.changes.iter().any(|change| { + change.uri == "ndf://pto-spec/PTO-INST-TILE-TMOV" && change.kind == PtoChangeKind::Moved + })); + assert!(impact.changes.iter().any(|change| { + change.uri == "ndf://pto-spec/PTO-INST-TILE-TOLD" + && change.kind == PtoChangeKind::Superseded + })); + assert_eq!( + serde_json::to_value(&impact).unwrap(), + golden("pto-impact-breaking.json") + ); +} + +#[test] +fn unchanged_release_still_emits_complete_consumer_inventory() { + let directory = tempfile::tempdir().unwrap(); + let before_path = index(directory.path(), "before.ndf.json", "pto-spec"); + let consumer_path = index(directory.path(), "consumer.ndf.json", "davincioo"); + let before = NdfIndex::open(&before_path).unwrap(); + let consumer = NdfIndex::open(&consumer_path).unwrap(); + + let impact = pto_release_impact(&before, &before, &consumer).unwrap(); + + assert_eq!(impact.conformance_targets.len(), 4); + assert_eq!(impact.absence_watches.len(), 1); + assert!(impact.changes.is_empty()); + assert!(impact.affected_consumers.is_empty()); +} diff --git a/crates/ndf-compiler/tests/rules.rs b/crates/ndf-compiler/tests/rules.rs index c4090a4..beb5faa 100644 --- a/crates/ndf-compiler/tests/rules.rs +++ b/crates/ndf-compiler/tests/rules.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; -use ndf_compiler::rules::{ValidationPolicy, validate_graph}; +use ndf_compiler::dependencies::DependencyGraph; +use ndf_compiler::rules::{ValidationPolicy, validate_graph, validate_project_graph}; use ndf_core::identity::NodeId; use ndf_core::model::{Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan}; @@ -27,6 +28,66 @@ fn codes(graph: &Graph) -> Vec { .collect() } +fn project_codes(graph: &Graph, dependencies: &BTreeMap) -> Vec { + validate_project_graph(graph, dependencies, &ValidationPolicy::default()) + .into_iter() + .map(|diagnostic| diagnostic.code) + .collect() +} + +fn dependency_graph(nodes: Vec) -> BTreeMap { + BTreeMap::from([( + "pto-spec".to_owned(), + DependencyGraph { + project_id: "pto-spec".to_owned(), + revision: "0123456789abcdef0123456789abcdef01234567".to_owned(), + graph: Graph { + nodes, + edges: Vec::new(), + diagnostics: Vec::new(), + }, + }, + )]) +} + +fn external_contract(local_id: &str) -> Node { + Node { + id: NodeId::new("pto-spec", local_id).unwrap(), + kind: NodeKind::Definition, + title: "PTO contract".to_owned(), + source: SourceSpan::new("asl/contracts.asl", 1).unwrap(), + modality: None, + refinement: Some("L1".to_owned()), + domain: Some("tile".to_owned()), + status: LifecycleStatus::Active, + owner: Some("pto-spec".to_owned()), + body: String::new(), + attributes: BTreeMap::new(), + } +} + +fn cross_project_graph(edge_type: &str, target: NodeId, expected_absent: bool) -> Graph { + let mut source = requirement("DavinciOO MUST preserve the PTO contract.", Some("core")); + if expected_absent { + source.modality = Some(Modality::Tbd); + source + .attributes + .insert("expected".to_owned(), "absent".to_owned()); + } + let edge = Edge::new( + source.id.clone(), + target, + edge_type, + Some(source.source.clone()), + ) + .unwrap(); + Graph { + nodes: vec![source], + edges: vec![edge], + diagnostics: Vec::new(), + } +} + #[test] fn dangling_mandatory_edge_is_rejected() { let source = requirement("The pipeline MUST issue in order.", Some("core")); @@ -102,3 +163,75 @@ fn lifecycle_and_owner_invariants_are_enforced() { assert_eq!(codes(&graph), ["NDF-LIFE-001", "NDF-OWN-001"]); } + +#[test] +fn resolved_conformance_target_passes() { + let target = external_contract("PTO-INST-TILE-TLOAD"); + let graph = cross_project_graph("conforms-to", target.id.clone(), false); + + assert_eq!( + project_codes(&graph, &dependency_graph(vec![target])), + Vec::::new() + ); +} + +#[test] +fn missing_conformance_target_is_rejected() { + let graph = cross_project_graph( + "conforms-to", + NodeId::new("pto-spec", "PTO-INST-TILE-MISSING").unwrap(), + false, + ); + + assert_eq!( + project_codes(&graph, &dependency_graph(Vec::new())), + ["NDF-XREF-002"] + ); +} + +#[test] +fn target_in_undeclared_project_is_rejected() { + let graph = cross_project_graph( + "conforms-to", + NodeId::new("another-spec", "EXT-001").unwrap(), + false, + ); + + assert_eq!(project_codes(&graph, &BTreeMap::new()), ["NDF-XREF-003"]); +} + +#[test] +fn expected_absent_watch_passes_while_target_is_missing() { + let graph = cross_project_graph( + "tracks", + NodeId::new("pto-spec", "PTO-INST-TILE-PEXT").unwrap(), + true, + ); + + assert_eq!( + project_codes(&graph, &dependency_graph(Vec::new())), + Vec::::new() + ); +} + +#[test] +fn expected_absent_watch_requires_review_when_target_appears() { + let target = external_contract("PTO-INST-TILE-PEXT"); + let graph = cross_project_graph("tracks", target.id.clone(), true); + + assert_eq!( + project_codes(&graph, &dependency_graph(vec![target])), + ["NDF-XREF-004"] + ); +} + +#[test] +fn expected_absent_watch_cannot_target_the_consumer_project() { + let graph = cross_project_graph( + "tracks", + NodeId::new("fixture", "PIPE-PROSPECTIVE-001").unwrap(), + true, + ); + + assert_eq!(project_codes(&graph, &BTreeMap::new()), ["NDF-XREF-001"]); +} diff --git a/crates/ndf-compiler/tests/scale.rs b/crates/ndf-compiler/tests/scale.rs index 39ab086..0528ae3 100644 --- a/crates/ndf-compiler/tests/scale.rs +++ b/crates/ndf-compiler/tests/scale.rs @@ -3,7 +3,8 @@ use std::io::{BufReader, Read}; use std::path::Path; use std::time::Instant; -use ndf_compiler::index::{BuildProvenance, build_index}; +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_compiler::pto_impact::pto_release_impact; use sha2::{Digest, Sha256}; #[path = "../../../tests/scale/generate.rs"] @@ -29,6 +30,9 @@ fn release_scale_contract() { let directory = tempfile::tempdir().unwrap(); let first_path = directory.path().join("first.sqlite"); let second_path = directory.path().join("second.sqlite"); + let pto_before_path = directory.path().join("pto-before.sqlite"); + let pto_after_path = directory.path().join("pto-after.sqlite"); + let consumer_path = directory.path().join("consumer.sqlite"); let started = Instant::now(); let first = build_index(&graph, &first_path, &provenance).unwrap(); @@ -45,12 +49,71 @@ fn release_scale_contract() { first_seconds < 10.0 && second_seconds < 10.0, "scale builds took {first_seconds:.3}s and {second_seconds:.3}s" ); + + build_index( + &generate::pto_graph(false), + &pto_before_path, + &impact_provenance("pto-spec"), + ) + .unwrap(); + build_index( + &generate::pto_graph(true), + &pto_after_path, + &impact_provenance("pto-spec"), + ) + .unwrap(); + build_index( + &generate::consumer_graph(), + &consumer_path, + &impact_provenance("davincioo"), + ) + .unwrap(); + let pto_before = NdfIndex::open(&pto_before_path).unwrap(); + let pto_after = NdfIndex::open(&pto_after_path).unwrap(); + let consumer = NdfIndex::open(&consumer_path).unwrap(); + + let started = Instant::now(); + let first_impact = pto_release_impact(&pto_before, &pto_after, &consumer).unwrap(); + let first_impact_seconds = started.elapsed().as_secs_f64(); + let started = Instant::now(); + let second_impact = pto_release_impact(&pto_before, &pto_after, &consumer).unwrap(); + let second_impact_seconds = started.elapsed().as_secs_f64(); + let first_impact_json = serde_json::to_vec(&first_impact).unwrap(); + let second_impact_json = serde_json::to_vec(&second_impact).unwrap(); + + assert_eq!( + first_impact.conformance_targets.len(), + generate::CONFORMANCE_EDGE_COUNT + ); + assert_eq!( + first_impact.affected_consumers.len(), + generate::CONSUMER_NODE_COUNT + ); + assert_eq!(first_impact_json, second_impact_json); + assert!( + first_impact_seconds < 10.0 && second_impact_seconds < 10.0, + "scale impacts took {first_impact_seconds:.3}s and {second_impact_seconds:.3}s" + ); println!( - "NDF_SCALE first_seconds={first_seconds:.6} second_seconds={second_seconds:.6} bytes={}", - first_path.metadata().unwrap().len() + "NDF_SCALE first_seconds={first_seconds:.6} second_seconds={second_seconds:.6} impact_first_seconds={first_impact_seconds:.6} impact_second_seconds={second_impact_seconds:.6} bytes={} impact_bytes={}", + first_path.metadata().unwrap().len(), + first_impact_json.len() ); } +fn impact_provenance(project: &str) -> BuildProvenance { + BuildProvenance { + project_commits: vec![( + project.to_owned(), + "0123456789abcdef0123456789abcdef01234567".to_owned(), + )], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + } +} + fn file_hash(path: &Path) -> [u8; 32] { let mut reader = BufReader::new(File::open(path).unwrap()); let mut hash = Sha256::new(); diff --git a/docs/adapters/pto-asl.md b/docs/adapters/pto-asl.md index 4225b0d..597ad9b 100644 --- a/docs/adapters/pto-asl.md +++ b/docs/adapters/pto-asl.md @@ -9,3 +9,12 @@ The adapter enforces PTO's current kind/level pairing, registered layers, identities, and `[[PTO-*]]` references. It returns canonical nodes, edges, and stable diagnostics; PTO-specific parser dataclasses are not part of the public NDF model. + +Instruction identity is derived only from `surface` and `mnemonic`, so moving +an ASL source does not change its `ndf://pto-spec/PTO-INST-*` URI. The adapter +indexes the instruction's semantic handler, catalog status, and every metadata- +supported facet from this sorted vocabulary: `architectural-state`, +`completion`, `encoding`, `fault`, `instruction-semantics`, `legality`, and +`memory-ordering`. It does not infer a facet that the metadata does not support. +Two declarations whose different surface/mnemonic spellings collapse to the +same stable URI are rejected with `NDF-ASL-005`. diff --git a/normative_language.md b/normative_language.md index d2d2c44..57ddcd5 100644 --- a/normative_language.md +++ b/normative_language.md @@ -161,7 +161,7 @@ spec/ # the spec root ("the bible") Three structures coexist: 1. **The tree** — the directory/heading hierarchy. This is the *ownership and maintenance* structure: every clause has exactly one home, and the tree is how humans navigate and how editing responsibility is divided. -2. **The graph** — typed cross-references between clauses (`refines`, `depends-on`, `conflicts-with`, `verifies`, `derived-from`, `couples-with`, plus plain mentions). This is the *semantic* structure; it is non-tree and freely crosses the hierarchy. +2. **The graph** — typed cross-references between clauses (`refines`, `depends-on`, `conflicts-with`, `verifies`, `derived-from`, `couples-with`, `conforms-to`, `tracks`, plus plain mentions). This is the *semantic* structure; it is non-tree and freely crosses the hierarchy. 3. **The history** — Git commits, plus explicit per-clause revision markers and decision records. This is the *evolution* structure. The discipline in one sentence: **prose lives in a tree, meaning lives in a graph, time lives in git — and stable clause IDs are the rivets holding all three together.** @@ -200,6 +200,7 @@ Rules: - `status` — `draft` / `stable` / `deprecated` / `superseded-by=ID`. - `since` — the spec version in which the clause entered its current substance. - **Cross-references** use `[[ID]]` (with optional `| display text`). The tool resolves them, fails the build on dangling refs, and generates a backlink index. Typed edges beyond plain reference are written as metadata: ``. +- **Cross-project contracts** use full `ndf://project/ID` identities. `conforms-to=` asserts that the consumer clause satisfies an external contract and therefore requires a target in an exact locked dependency graph. `tracks=` records a prospective external identity without asserting conformance; it is valid only with `expected=absent`. When a tracked identity becomes present, the compiler requires review instead of silently changing the consumer clause. - **Normative keywords** (MUST/SHOULD/MAY/MUST NOT) are used per RFC 2119 inside `req` clauses; the linter flags `req` clauses containing none, and flags MUST/SHALL appearing in `info` clauses. Everything outside clause blocks — introductory prose, figures, examples — is informative by default. **The normative/informative boundary is thus syntactically explicit**, which is the single biggest ambiguity in traditional specs. @@ -491,6 +492,12 @@ commit. Release and CI builds MUST resolve checked-out dependencies against that lock and MUST NOT fetch an unpinned branch. Local dirty overrides are non-release unless an explicit project policy allows them. +A dependency declaration is tool-only by default. Set `graph: true` only when +the dependency publishes an `ndf.yaml` project whose nodes are valid external +relationship targets. The compiler still verifies the exact revision and +cleanliness of every tool-only dependency, but it loads and validates a +read-only external graph only for explicitly marked graph dependencies. + Legacy syntax is accepted only through named adapters. The DavinciOO adapter maps `kind=req`, `level=must`, `layer=L1`, and `status=stable` to canonical `kind=requirement`, `modality=must`, `refinement=L1`, and `status=active`, while @@ -582,7 +589,8 @@ key := "kind" | "level" | "layer" | "status" | "since" | "refines" | "depends-on" | "conflicts-with" | "verifies" | "origin" | "origin-status" | "model" | "affects" | "blocks" | "date" - | "couples-with" | "default" | "explore" | "unit" + | "couples-with" | "conforms-to" | "tracks" + | "expected" | "default" | "explore" | "unit" body := markdown, containing: [[ID]] | [[ID | text]] cross-references ```lang ndf:normative ... ``` normative code islands diff --git a/normative_language_cn.md b/normative_language_cn.md index 88aeb19..2ef656c 100644 --- a/normative_language_cn.md +++ b/normative_language_cn.md @@ -161,7 +161,7 @@ spec/ # 规范根目录("圣经") 三种结构共存: 1. **树**——目录/标题层级。这是*归属与维护*结构:每个条款有且仅有一个家,树是人类导航的方式,也是编辑责任划分的方式。 -2. **图**——条款之间带类型的交叉引用(`refines`、`depends-on`、`conflicts-with`、`verifies`、`derived-from`、`couples-with`,外加普通提及)。这是*语义*结构;它是非树的,可以自由穿越层级。 +2. **图**——条款之间带类型的交叉引用(`refines`、`depends-on`、`conflicts-with`、`verifies`、`derived-from`、`couples-with`、`conforms-to`、`tracks`,外加普通提及)。这是*语义*结构;它是非树的,可以自由穿越层级。 3. **历史**——Git 提交,加上显式的条款级修订标记与决策记录。这是*演化*结构。 一句话概括这套纪律:**散文活在树里,语义活在图里,时间活在 git 里——而稳定的条款 ID 是把三者铆在一起的铆钉。** @@ -200,6 +200,7 @@ Frames failing any condition MUST be discarded and the per-port - `status` —— `draft` / `stable` / `deprecated` / `superseded-by=ID`。 - `since` —— 该条款当前实质内容进入规范时的版本号。 - **交叉引用**使用 `[[ID]]`(可选加 `| 显示文本`)。工具负责解析,遇到悬空引用即令构建失败,并生成反向链接索引。普通引用之外的带类型的边写在元数据中:``。 +- **跨项目契约**使用完整的 `ndf://project/ID` 身份。`conforms-to=` 断言消费方条款满足一个外部契约,因此其目标必须存在于精确锁定的依赖图中。`tracks=` 只记录一个预期的外部身份,不断言符合性;它仅能与 `expected=absent` 一起使用。当被追踪的身份变为存在时,编译器要求评审,而不会静默修改消费方条款。 - **规范性关键词**(MUST/SHOULD/MAY/MUST NOT)按 RFC 2119 在 `req` 条款内使用;linter 会标记不含任何关键词的 `req` 条款,也会标记出现在 `info` 条款中的 MUST/SHALL。 条款块之外的一切——引言散文、图、示例——默认为资料性(informative)。**规范性与资料性的边界由此在语法上被显式化**,而这正是传统规范中最大的一处含混。 @@ -488,6 +489,11 @@ The core MUST issue no more than two instructions per cycle. lock 校验已检出的依赖,禁止获取未锁定分支。脏的本地覆盖不是发布构建, 除非项目策略明确允许。 +依赖声明默认只代表工具依赖。只有当依赖发布了 `ndf.yaml` 项目、且其节点 +可以作为有效的外部关系目标时,才设置 `graph: true`。编译器仍会校验每个 +工具依赖的精确 revision 和洁净度,但只为显式标记的图依赖加载并校验只读 +外部图。 + 旧语法只能通过具名 adapter 接受。DavinciOO adapter 把 `kind=req`、 `level=must`、`layer=L1`、`status=stable` 映射为规范的 `kind=requirement`、`modality=must`、`refinement=L1`、`status=active`, @@ -576,7 +582,8 @@ key := "kind" | "level" | "layer" | "status" | "since" | "refines" | "depends-on" | "conflicts-with" | "verifies" | "origin" | "origin-status" | "model" | "affects" | "blocks" | "date" - | "couples-with" | "default" | "explore" | "unit" + | "couples-with" | "conforms-to" | "tracks" + | "expected" | "default" | "explore" | "unit" body := markdown,其中可包含: [[ID]] | [[ID | text]] 交叉引用 ```lang ndf:normative ... ``` 规范性代码岛 diff --git a/tests/fixtures/cli-smoke/project/docs/smoke.md b/tests/fixtures/cli-smoke/project/docs/smoke.md new file mode 100644 index 0000000..baac1a6 --- /dev/null +++ b/tests/fixtures/cli-smoke/project/docs/smoke.md @@ -0,0 +1,13 @@ +--- +doc_id: DOC-CLI-SMOKE +status: active +authority: normative +owner: core +--- + +# CLI smoke fixture + +## Self-contained contract {#SMOKE-001} + + +The CLI smoke fixture MUST remain self-contained. diff --git a/tests/fixtures/cli-smoke/project/ndf.lock b/tests/fixtures/cli-smoke/project/ndf.lock new file mode 100644 index 0000000..0c1d5c9 --- /dev/null +++ b/tests/fixtures/cli-smoke/project/ndf.lock @@ -0,0 +1,2 @@ +format_version: "0.1" +dependencies: {} diff --git a/tests/fixtures/cli-smoke/project/ndf.yaml b/tests/fixtures/cli-smoke/project/ndf.yaml new file mode 100644 index 0000000..ad8e583 --- /dev/null +++ b/tests/fixtures/cli-smoke/project/ndf.yaml @@ -0,0 +1,11 @@ +format_version: "0.2" +project: cli-smoke +roots: + - docs/**/*.md +id_prefixes: + - DOC + - SMOKE +domains: + - core +policies: {} +dependencies: {} diff --git a/tests/fixtures/markdown/project/docs/pipeline.md b/tests/fixtures/markdown/project/docs/pipeline.md index 6c4b780..b85d2ed 100644 --- a/tests/fixtures/markdown/project/docs/pipeline.md +++ b/tests/fixtures/markdown/project/docs/pipeline.md @@ -20,3 +20,13 @@ The core MUST issue no more than two instructions per cycle. Two issue slots MUST arbitrate oldest-ready first. + +## PTO conformance {#PIPE-PTO-001} + + +The pipeline MUST preserve the PTO load contract. + +## Prospective PTO extension {#PIPE-PTO-EXT-001} + + +The extension remains outside the accepted PTO release. diff --git a/tests/fixtures/pto-asl/accept.asl b/tests/fixtures/pto-asl/accept.asl index 3af1263..b988a87 100644 --- a/tests/fixtures/pto-asl/accept.asl +++ b/tests/fixtures/pto-asl/accept.asl @@ -1,4 +1,4 @@ -// PTO-INSTRUCTION: {"surface":"tile","mnemonic":"TLOAD"} +// PTO-INSTRUCTION: {"assembly":["TLOAD "],"catalog_records":[{"contract_status":"reviewed-complete","effect_contract":"TLOAD","fault_contract":"ExecuteTileInstruction","function":0,"legality_handler":"TileOperandsLegal_TLOAD","memory_ordering_contract":"PTO-TSO","restart_contract":"CompleteBundleAtWithAcceptedApplicabilityRules","selector":"0x078","semantic_handler":"TLOAD","state_effects":["operand:destination0:destination"]}],"mnemonic":"TLOAD","surface":"tile"} // NDF-BEGIN: PTO-TILE-CAPACITY // ndf: kind=contract level=L1 layer=tile status=accepted diff --git a/tests/fixtures/pto-impact/after-breaking.ndf.json b/tests/fixtures/pto-impact/after-breaking.ndf.json new file mode 100644 index 0000000..e8d2139 --- /dev/null +++ b/tests/fixtures/pto-impact/after-breaking.ndf.json @@ -0,0 +1,12 @@ +{ + "nodes": [ + {"id":"ndf://pto-spec/PTO-INST-TILE-TLOAD","source":"asl/tile/TLOAD.asl","semantic_handler":"ExecuteTileLoadV2","semantic_facets":"architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TMOV","source":"asl/tile/data-movement/TMOV.asl","semantic_handler":"TMOV","semantic_facets":"architectural-state,instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TNEW","source":"asl/tile/TNEW.asl","semantic_handler":"TNEW","semantic_facets":"instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TOLD","source":"asl/tile/TOLD.asl","semantic_handler":"TOLD","semantic_facets":"instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TWATCH","source":"asl/tile/TWATCH.asl","semantic_handler":"TWATCH","semantic_facets":"instruction-semantics"} + ], + "edges": [ + {"source":"ndf://pto-spec/PTO-INST-TILE-TNEW","type":"supersedes","target":"ndf://pto-spec/PTO-INST-TILE-TOLD"} + ] +} diff --git a/tests/fixtures/pto-impact/after-compatible.ndf.json b/tests/fixtures/pto-impact/after-compatible.ndf.json new file mode 100644 index 0000000..d45dfdb --- /dev/null +++ b/tests/fixtures/pto-impact/after-compatible.ndf.json @@ -0,0 +1,10 @@ +{ + "nodes": [ + {"id":"ndf://pto-spec/PTO-ARCH-UNRELATED","source":"asl/arch/unrelated.asl","body":"A new unrelated contract."}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TLOAD","source":"asl/tile/TLOAD.asl","semantic_handler":"TLOAD","semantic_facets":"architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TMOV","source":"asl/tile/TMOV.asl","semantic_handler":"TMOV","semantic_facets":"architectural-state,instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TOLD","source":"asl/tile/TOLD.asl","semantic_handler":"TOLD","semantic_facets":"instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TSTORE","source":"asl/tile/TSTORE.asl","semantic_handler":"TSTORE","semantic_facets":"architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering"} + ], + "edges": [] +} diff --git a/tests/fixtures/pto-impact/before.ndf.json b/tests/fixtures/pto-impact/before.ndf.json new file mode 100644 index 0000000..2ce07a2 --- /dev/null +++ b/tests/fixtures/pto-impact/before.ndf.json @@ -0,0 +1,9 @@ +{ + "nodes": [ + {"id":"ndf://pto-spec/PTO-INST-TILE-TLOAD","source":"asl/tile/TLOAD.asl","semantic_handler":"TLOAD","semantic_facets":"architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TMOV","source":"asl/tile/TMOV.asl","semantic_handler":"TMOV","semantic_facets":"architectural-state,instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TOLD","source":"asl/tile/TOLD.asl","semantic_handler":"TOLD","semantic_facets":"instruction-semantics,legality"}, + {"id":"ndf://pto-spec/PTO-INST-TILE-TSTORE","source":"asl/tile/TSTORE.asl","semantic_handler":"TSTORE","semantic_facets":"architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering"} + ], + "edges": [] +} diff --git a/tests/fixtures/pto-impact/consumer.ndf.json b/tests/fixtures/pto-impact/consumer.ndf.json new file mode 100644 index 0000000..6635e61 --- /dev/null +++ b/tests/fixtures/pto-impact/consumer.ndf.json @@ -0,0 +1,21 @@ +{ + "nodes": [ + {"id":"ndf://davincioo/DAV-ISA-TLOAD","source":"docs/isa/tload.md"}, + {"id":"ndf://davincioo/DAV-ISA-TMOV","source":"docs/isa/tmov.md"}, + {"id":"ndf://davincioo/DAV-ISA-TOLD","source":"docs/isa/told.md"}, + {"id":"ndf://davincioo/DAV-ISA-TSTORE","source":"docs/isa/tstore.md"}, + {"id":"ndf://davincioo/DAV-PIPE-TLOAD","source":"docs/pipeline/tload.md"}, + {"id":"ndf://davincioo/DAV-TEST-TLOAD","source":"docs/tests/tload.md"}, + {"id":"ndf://davincioo/DAV-UNRELATED","source":"docs/unrelated.md"}, + {"id":"ndf://davincioo/DAV-WATCH-TWATCH","source":"docs/isa/watch.md","expected":"absent"} + ], + "edges": [ + {"source":"ndf://davincioo/DAV-ISA-TLOAD","type":"conforms-to","target":"ndf://pto-spec/PTO-INST-TILE-TLOAD"}, + {"source":"ndf://davincioo/DAV-ISA-TMOV","type":"conforms-to","target":"ndf://pto-spec/PTO-INST-TILE-TMOV"}, + {"source":"ndf://davincioo/DAV-ISA-TOLD","type":"conforms-to","target":"ndf://pto-spec/PTO-INST-TILE-TOLD"}, + {"source":"ndf://davincioo/DAV-ISA-TSTORE","type":"conforms-to","target":"ndf://pto-spec/PTO-INST-TILE-TSTORE"}, + {"source":"ndf://davincioo/DAV-PIPE-TLOAD","type":"refines","target":"ndf://davincioo/DAV-ISA-TLOAD"}, + {"source":"ndf://davincioo/DAV-TEST-TLOAD","type":"verifies","target":"ndf://davincioo/DAV-PIPE-TLOAD"}, + {"source":"ndf://davincioo/DAV-WATCH-TWATCH","type":"tracks","target":"ndf://pto-spec/PTO-INST-TILE-TWATCH"} + ] +} diff --git a/tests/golden/markdown-valid.json b/tests/golden/markdown-valid.json index e0862a8..26487fb 100644 --- a/tests/golden/markdown-valid.json +++ b/tests/golden/markdown-valid.json @@ -13,6 +13,32 @@ }, "target": "ndf://fixture/PIPE-ISSUE-001", "type": "refines" + }, + { + "attributes": {}, + "source": "ndf://fixture/PIPE-PTO-001", + "source_span": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 25, + "path": "docs/pipeline.md" + }, + "target": "ndf://pto-spec/PTO-INST-TILE-TLOAD", + "type": "conforms-to" + }, + { + "attributes": {}, + "source": "ndf://fixture/PIPE-PTO-EXT-001", + "source_span": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 30, + "path": "docs/pipeline.md" + }, + "target": "ndf://pto-spec/PTO-INST-TILE-PEXT", + "type": "tracks" } ], "nodes": [ @@ -74,6 +100,46 @@ }, "status": "active", "title": "Issue mechanism" + }, + { + "attributes": {}, + "body": "The pipeline MUST preserve the PTO load contract.", + "domain": "core", + "id": "ndf://fixture/PIPE-PTO-001", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 24, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "PTO conformance" + }, + { + "attributes": { + "expected": "absent" + }, + "body": "The extension remains outside the accepted PTO release.", + "domain": "core", + "id": "ndf://fixture/PIPE-PTO-EXT-001", + "kind": "requirement", + "modality": "tbd", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 29, + "path": "docs/pipeline.md" + }, + "status": "draft", + "title": "Prospective PTO extension" } ], "schema_version": "0.1" diff --git a/tests/golden/pto-asl.json b/tests/golden/pto-asl.json index 3448991..c4480c3 100644 --- a/tests/golden/pto-asl.json +++ b/tests/golden/pto-asl.json @@ -20,6 +20,9 @@ { "attributes": { "mnemonic": "TLOAD", + "semantic_facets": "architectural-state,completion,encoding,fault,instruction-semantics,legality,memory-ordering", + "semantic_handler": "TLOAD", + "status": "reviewed-complete", "surface": "tile" }, "body": "", diff --git a/tests/golden/pto-impact-breaking.json b/tests/golden/pto-impact-breaking.json new file mode 100644 index 0000000..3fa7ca8 --- /dev/null +++ b/tests/golden/pto-impact-breaking.json @@ -0,0 +1,36 @@ +{ + "schema_version": "1", + "compatibility": "breaking", + "conformance_targets": [ + {"consumer_uri":"ndf://davincioo/DAV-ISA-TLOAD","target_uri":"ndf://pto-spec/PTO-INST-TILE-TLOAD"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TMOV","target_uri":"ndf://pto-spec/PTO-INST-TILE-TMOV"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TOLD","target_uri":"ndf://pto-spec/PTO-INST-TILE-TOLD"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TSTORE","target_uri":"ndf://pto-spec/PTO-INST-TILE-TSTORE"} + ], + "absence_watches": [ + {"consumer_uri":"ndf://davincioo/DAV-WATCH-TWATCH","target_uri":"ndf://pto-spec/PTO-INST-TILE-TWATCH","expected":"absent","present":true} + ], + "changes": [ + {"uri":"ndf://pto-spec/PTO-INST-TILE-TLOAD","kind":"modified","facets":["instruction-semantics"]}, + {"uri":"ndf://pto-spec/PTO-INST-TILE-TMOV","kind":"moved","facets":["architectural-state","instruction-semantics","legality"]}, + {"uri":"ndf://pto-spec/PTO-INST-TILE-TNEW","kind":"added","facets":["instruction-semantics","legality"]}, + {"uri":"ndf://pto-spec/PTO-INST-TILE-TOLD","kind":"superseded","facets":["instruction-semantics","legality"]}, + {"uri":"ndf://pto-spec/PTO-INST-TILE-TSTORE","kind":"removed","facets":["architectural-state","completion","encoding","fault","instruction-semantics","legality","memory-ordering"]}, + {"uri":"ndf://pto-spec/PTO-INST-TILE-TWATCH","kind":"added","facets":["instruction-semantics"]} + ], + "affected_consumers": [ + {"uri":"ndf://davincioo/DAV-ISA-TLOAD","direct":true,"reasons":["modified conformance target: ndf://pto-spec/PTO-INST-TILE-TLOAD"]}, + {"uri":"ndf://davincioo/DAV-ISA-TMOV","direct":true,"reasons":["moved conformance target: ndf://pto-spec/PTO-INST-TILE-TMOV"]}, + {"uri":"ndf://davincioo/DAV-ISA-TOLD","direct":true,"reasons":["superseded conformance target: ndf://pto-spec/PTO-INST-TILE-TOLD"]}, + {"uri":"ndf://davincioo/DAV-ISA-TSTORE","direct":true,"reasons":["removed conformance target: ndf://pto-spec/PTO-INST-TILE-TSTORE"]}, + {"uri":"ndf://davincioo/DAV-PIPE-TLOAD","direct":false,"reasons":["modified conformance target: ndf://pto-spec/PTO-INST-TILE-TLOAD"]}, + {"uri":"ndf://davincioo/DAV-TEST-TLOAD","direct":false,"reasons":["modified conformance target: ndf://pto-spec/PTO-INST-TILE-TLOAD"]}, + {"uri":"ndf://davincioo/DAV-WATCH-TWATCH","direct":true,"reasons":["expected-absent target became present: ndf://pto-spec/PTO-INST-TILE-TWATCH"]} + ], + "presence_changes": ["ndf://pto-spec/PTO-INST-TILE-TWATCH"], + "deterministic_actions": [ + "block automatic PTO release adoption", + "rebuild and validate affected consumer closure" + ], + "human_decisions": ["approve each consumer conformance migration or waiver"] +} diff --git a/tests/golden/pto-impact-compatible.json b/tests/golden/pto-impact-compatible.json new file mode 100644 index 0000000..e422d81 --- /dev/null +++ b/tests/golden/pto-impact-compatible.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1", + "compatibility": "compatible", + "conformance_targets": [ + {"consumer_uri":"ndf://davincioo/DAV-ISA-TLOAD","target_uri":"ndf://pto-spec/PTO-INST-TILE-TLOAD"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TMOV","target_uri":"ndf://pto-spec/PTO-INST-TILE-TMOV"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TOLD","target_uri":"ndf://pto-spec/PTO-INST-TILE-TOLD"}, + {"consumer_uri":"ndf://davincioo/DAV-ISA-TSTORE","target_uri":"ndf://pto-spec/PTO-INST-TILE-TSTORE"} + ], + "absence_watches": [ + {"consumer_uri":"ndf://davincioo/DAV-WATCH-TWATCH","target_uri":"ndf://pto-spec/PTO-INST-TILE-TWATCH","expected":"absent","present":false} + ], + "changes": [ + {"uri":"ndf://pto-spec/PTO-ARCH-UNRELATED","kind":"added","facets":[]} + ], + "affected_consumers": [], + "presence_changes": [], + "deterministic_actions": ["record compatible PTO release impact"], + "human_decisions": [] +} diff --git a/tests/golden/query-report.json b/tests/golden/query-report.json index 2d41403..a193fac 100644 --- a/tests/golden/query-report.json +++ b/tests/golden/query-report.json @@ -1,9 +1,12 @@ { "coverage": { "open_items": [], - "tbd": [], + "tbd": [ + "ndf://fixture/PIPE-PTO-EXT-001" + ], "unverified": [ - "ndf://fixture/PIPE-ISSUE-001" + "ndf://fixture/PIPE-ISSUE-001", + "ndf://fixture/PIPE-PTO-001" ] }, "query": [ @@ -44,6 +47,25 @@ }, "status": "active", "title": "Issue mechanism" + }, + { + "attributes": {}, + "body": "The pipeline MUST preserve the PTO load contract.", + "domain": "core", + "id": "ndf://fixture/PIPE-PTO-001", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 24, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "PTO conformance" } ], "trace": { diff --git a/tests/scale/generate.rs b/tests/scale/generate.rs index 8cf6fe6..2e4b806 100644 --- a/tests/scale/generate.rs +++ b/tests/scale/generate.rs @@ -5,6 +5,8 @@ use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; pub const NODE_COUNT: usize = 100_000; pub const EDGE_COUNT: usize = 1_000_000; +pub const CONSUMER_NODE_COUNT: usize = 10_000; +pub const CONFORMANCE_EDGE_COUNT: usize = CONSUMER_NODE_COUNT / 10; pub fn graph() -> Graph { let nodes: Vec<_> = (0..NODE_COUNT) @@ -39,3 +41,86 @@ pub fn graph() -> Graph { diagnostics: Vec::new(), } } + +pub fn pto_graph(modified: bool) -> Graph { + let nodes = (0..CONFORMANCE_EDGE_COUNT) + .map(|index| { + let handler = if modified && index == 0 { + "ExecuteTileOperationV2" + } else { + "ExecuteTileOperation" + }; + Node { + id: NodeId::new("pto-spec", format!("PTO-INST-TILE-T{index:04}")).unwrap(), + kind: NodeKind::Definition, + title: format!("Tile operation {index}"), + source: SourceSpan::new("asl/tile/generated.asl", (index + 1) as u32).unwrap(), + modality: None, + refinement: None, + domain: Some("tile".to_owned()), + status: LifecycleStatus::Active, + owner: Some("pto-spec".to_owned()), + body: String::new(), + attributes: BTreeMap::from([ + ("semantic_handler".to_owned(), handler.to_owned()), + ( + "semantic_facets".to_owned(), + "instruction-semantics".to_owned(), + ), + ]), + } + }) + .collect(); + Graph { + nodes, + edges: Vec::new(), + diagnostics: Vec::new(), + } +} + +pub fn consumer_graph() -> Graph { + let nodes: Vec<_> = (0..CONSUMER_NODE_COUNT) + .map(|index| Node { + id: NodeId::new("davincioo", format!("DAV-NODE-{index:05}")).unwrap(), + kind: NodeKind::Architecture, + title: format!("Consumer node {index}"), + source: SourceSpan::new("docs/generated.md", (index + 1) as u32).unwrap(), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: Some("davincioo".to_owned()), + body: String::new(), + attributes: BTreeMap::new(), + }) + .collect(); + let mut edges: Vec<_> = (0..CONFORMANCE_EDGE_COUNT) + .map(|index| { + Edge::new( + nodes[index].id.clone(), + NodeId::new("pto-spec", format!("PTO-INST-TILE-T{index:04}")).unwrap(), + "conforms-to", + None, + ) + .unwrap() + }) + .collect(); + edges.extend((1..CONSUMER_NODE_COUNT).map(|index| { + Edge::new( + nodes[index].id.clone(), + nodes[index - 1].id.clone(), + if index % 2 == 0 { + "refines" + } else { + "verifies" + }, + None, + ) + .unwrap() + })); + Graph { + nodes, + edges, + diagnostics: Vec::new(), + } +}