From d7ccc92e75b3ab2b3baa1c1a1d085a76d69c575b Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:38:06 +0800 Subject: [PATCH 1/8] feat(format): add cross-project conformance relationships --- crates/ndf-compiler/src/markdown.rs | 40 ++++++++ crates/ndf-compiler/tests/markdown.rs | 97 +++++++++++++++++++ normative_language.md | 6 +- normative_language_cn.md | 6 +- .../markdown/project/docs/pipeline.md | 10 ++ tests/golden/markdown-valid.json | 66 +++++++++++++ tests/golden/query-report.json | 26 ++++- 7 files changed, 245 insertions(+), 6 deletions(-) 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/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/normative_language.md b/normative_language.md index d2d2c44..9f01bef 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. @@ -582,7 +583,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..e729095 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)。**规范性与资料性的边界由此在语法上被显式化**,而这正是传统规范中最大的一处含混。 @@ -576,7 +577,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/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/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/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": { From 86f70f6c00f4a5ba646f2a9359d5116646ada56d Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:40:44 +0800 Subject: [PATCH 2/8] feat(deps): load exact read-only dependency graphs --- crates/ndf-compiler/src/dependencies.rs | 64 ++++++++++ crates/ndf-compiler/src/manifest.rs | 17 ++- crates/ndf-compiler/tests/dependencies.rs | 135 +++++++++++++++++++++- crates/ndf-compiler/tests/manifest.rs | 53 +++++++++ normative_language.md | 6 + normative_language_cn.md | 5 + 6 files changed, 273 insertions(+), 7 deletions(-) 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/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/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/normative_language.md b/normative_language.md index 9f01bef..57ddcd5 100644 --- a/normative_language.md +++ b/normative_language.md @@ -492,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 diff --git a/normative_language_cn.md b/normative_language_cn.md index e729095..2ef656c 100644 --- a/normative_language_cn.md +++ b/normative_language_cn.md @@ -489,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`, From 219eea1440bfa9facf04e4c5b0985e2be4c32246 Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:45:04 +0800 Subject: [PATCH 3/8] feat(validate): resolve external NDF relationships --- crates/ndf-cli/src/run.rs | 8 +- crates/ndf-cli/tests/commands.rs | 124 +++++++++++++++++++++++++- crates/ndf-compiler/src/rules.rs | 135 ++++++++++++++++++++++++++++- crates/ndf-compiler/tests/rules.rs | 135 ++++++++++++++++++++++++++++- 4 files changed, 394 insertions(+), 8 deletions(-) diff --git a/crates/ndf-cli/src/run.rs b/crates/ndf-cli/src/run.rs index 3b584f3..2d49316 100644 --- a/crates/ndf-cli/src/run.rs +++ b/crates/ndf-cli/src/run.rs @@ -4,7 +4,7 @@ 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}; @@ -13,7 +13,7 @@ use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; use ndf_compiler::manifest::{ProjectLock, ProjectManifest}; 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}; @@ -182,8 +182,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..273ef7f 100644 --- a/crates/ndf-cli/tests/commands.rs +++ b/crates/ndf-cli/tests/commands.rs @@ -33,11 +33,115 @@ 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 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 +281,19 @@ 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"); +} 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/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"]); +} From e3f85d6133eae6c19de716bdfa409ad8a7fd6945 Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:49:14 +0800 Subject: [PATCH 4/8] feat(pto): index stable ASL identities and facets --- crates/ndf-compiler/src/adapters/mod.rs | 37 +++++- crates/ndf-compiler/src/adapters/pto_asl.rs | 133 +++++++++++++++++++- crates/ndf-compiler/tests/pto_asl.rs | 63 +++++++++- tests/fixtures/pto-asl/accept.asl | 2 +- tests/golden/pto-asl.json | 3 + 5 files changed, 229 insertions(+), 9 deletions(-) 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/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/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/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": "", From b513ca5c09bd7304a74127f278945ce64d024136 Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:55:48 +0800 Subject: [PATCH 5/8] feat(impact): classify PTO release effects --- crates/ndf-compiler/src/index.rs | 50 ++- crates/ndf-compiler/src/lib.rs | 1 + crates/ndf-compiler/src/pto_impact.rs | 364 ++++++++++++++++++ crates/ndf-compiler/tests/pto_impact.rs | 175 +++++++++ .../pto-impact/after-breaking.ndf.json | 12 + .../pto-impact/after-compatible.ndf.json | 10 + tests/fixtures/pto-impact/before.ndf.json | 9 + tests/fixtures/pto-impact/consumer.ndf.json | 21 + tests/golden/pto-impact-breaking.json | 36 ++ tests/golden/pto-impact-compatible.json | 20 + 10 files changed, 687 insertions(+), 11 deletions(-) create mode 100644 crates/ndf-compiler/src/pto_impact.rs create mode 100644 crates/ndf-compiler/tests/pto_impact.rs create mode 100644 tests/fixtures/pto-impact/after-breaking.ndf.json create mode 100644 tests/fixtures/pto-impact/after-compatible.ndf.json create mode 100644 tests/fixtures/pto-impact/before.ndf.json create mode 100644 tests/fixtures/pto-impact/consumer.ndf.json create mode 100644 tests/golden/pto-impact-breaking.json create mode 100644 tests/golden/pto-impact-compatible.json 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/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/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/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/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": [] +} From a1b4725e91dcdee7d7b2b010317eaff86dab8d49 Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 09:57:40 +0800 Subject: [PATCH 6/8] feat(cli): add PTO release impact command --- README.md | 24 +++++++ crates/ndf-cli/src/args.rs | 21 ++++++ crates/ndf-cli/src/run.rs | 21 +++++- crates/ndf-cli/tests/commands.rs | 110 +++++++++++++++++++++++++++++++ crates/ndf-cli/tests/errors.rs | 32 +++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe2ef9a..40c1968 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,30 @@ 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. + ## Documentation - [English specification](normative_language.md) 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 2d49316..24b16d4 100644 --- a/crates/ndf-cli/src/run.rs +++ b/crates/ndf-cli/src/run.rs @@ -11,6 +11,7 @@ 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_project_graph}; @@ -19,7 +20,7 @@ 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, diff --git a/crates/ndf-cli/tests/commands.rs b/crates/ndf-cli/tests/commands.rs index 273ef7f..38b65f6 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 { @@ -89,6 +93,85 @@ fn write_lock(root: &Path, revision: &str) { .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); @@ -297,3 +380,30 @@ fn check_rejects_a_missing_cross_project_conformance_target() { 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"); +} 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()); + } +} From 39f887b45582e6368e1790f85c95acfb955a3b56 Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 10:00:08 +0800 Subject: [PATCH 7/8] docs(release): document cross-project impact contract --- CHANGELOG.md | 14 +++++ README.md | 30 +++++++++++ crates/ndf-compiler/tests/scale.rs | 69 ++++++++++++++++++++++-- docs/adapters/pto-asl.md | 9 ++++ tests/scale/generate.rs | 85 ++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 3 deletions(-) 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 40c1968..3a95a22 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,34 @@ 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) @@ -61,3 +89,5 @@ byte. 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-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/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(), + } +} From 60930720e21e8afc66fd4a711c02560d86e6e00d Mon Sep 17 00:00:00 2001 From: RuoyuZhou Date: Tue, 11 Aug 2026 10:05:03 +0800 Subject: [PATCH 8/8] test(ci): separate CLI smoke fixture --- .github/workflows/ci.yml | 2 +- crates/ndf-cli/tests/commands.rs | 14 ++++++++++++++ tests/fixtures/cli-smoke/project/docs/smoke.md | 13 +++++++++++++ tests/fixtures/cli-smoke/project/ndf.lock | 2 ++ tests/fixtures/cli-smoke/project/ndf.yaml | 11 +++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/cli-smoke/project/docs/smoke.md create mode 100644 tests/fixtures/cli-smoke/project/ndf.lock create mode 100644 tests/fixtures/cli-smoke/project/ndf.yaml 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/crates/ndf-cli/tests/commands.rs b/crates/ndf-cli/tests/commands.rs index 38b65f6..f04077f 100644 --- a/crates/ndf-cli/tests/commands.rs +++ b/crates/ndf-cli/tests/commands.rs @@ -407,3 +407,17 @@ fn pto_release_impact_emits_a_deterministic_versioned_envelope() { 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/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: {}