Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 122 additions & 20 deletions crates/ndf-compiler/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const EDGE_FIELDS: &[&str] = &[
"supersedes",
"references",
"evidenced-by",
"couples-with",
"blocks-by",
];
const CANONICAL_FIELDS: &[&str] = &[
"kind",
Expand All @@ -34,6 +36,18 @@ const CANONICAL_FIELDS: &[&str] = &[
"status",
"owner",
];
const ATTRIBUTE_FIELDS: &[&str] = &[
"since",
"date",
"author",
"unit",
"default",
"explore",
"origin",
"origin-status",
"model",
"superseded-by",
];

pub fn parse_markdown(
path: &Path,
Expand Down Expand Up @@ -183,6 +197,20 @@ pub fn parse_markdown(
builder.add_diagnostic(invalid_value(&relative_path, metadata_index + 1, "status"));
continue;
};
let mut attributes = BTreeMap::new();
for field in ATTRIBUTE_FIELDS {
if let Some(value) = metadata.get(*field) {
attributes.insert((*field).to_owned(), value.clone());
}
}
if let Some(value) = metadata.get("derived-from")
&& value
.split(',')
.map(str::trim)
.any(|target| NodeId::parse(target, Some(&project.project)).is_err())
{
attributes.insert("derived-from".to_owned(), value.clone());
}
let node = Node {
id: NodeId::new(&project.project, local_id)
.map_err(|error| CompilerError::Contract(error.to_string()))?,
Expand All @@ -201,7 +229,7 @@ pub fn parse_markdown(
.unwrap_or_else(|| owner.clone()),
),
body: body(&lines, metadata_index + 1),
attributes: BTreeMap::new(),
attributes,
};
let source_id = node.id.clone();
builder.add_node(node);
Expand All @@ -210,11 +238,15 @@ pub fn parse_markdown(
continue;
};
for target in targets.split(',').map(str::trim) {
let target_id = match NodeId::parse(target, Some(&project.project)) {
Ok(target_id) => target_id,
Err(_) if *edge_type == "derived-from" => continue,
Err(error) => return Err(CompilerError::Contract(error.to_string())),
};
builder.add_edge(
Edge::new(
source_id.clone(),
NodeId::parse(target, Some(&project.project))
.map_err(|error| CompilerError::Contract(error.to_string()))?,
target_id,
*edge_type,
Some(
SourceSpan::new(&relative_path, (metadata_index + 1) as u32)
Expand Down Expand Up @@ -378,21 +410,22 @@ fn parse_metadata(
else {
return (None, Vec::new());
};
let tokens = metadata_tokens(content);
if tokens.is_empty() && !content.trim().is_empty() {
return (
None,
vec![diagnostic(
"NDF-MD-001",
DiagnosticSeverity::Error,
format!("invalid NDF metadata token: {}", content.trim()),
path,
line_number,
"Write metadata as key=value tokens.",
)],
);
}
let mut metadata = BTreeMap::new();
for token in content.split_whitespace() {
let Some((name, value)) = token.split_once('=') else {
return (
None,
vec![diagnostic(
"NDF-MD-001",
DiagnosticSeverity::Error,
format!("invalid NDF metadata token: {token}"),
path,
line_number,
"Write metadata as key=value tokens.",
)],
);
};
for (name, value) in tokens {
if metadata.contains_key(name) {
return (
None,
Expand All @@ -411,6 +444,7 @@ fn parse_metadata(
let allowed: BTreeSet<_> = CANONICAL_FIELDS
.iter()
.chain(EDGE_FIELDS)
.chain(ATTRIBUTE_FIELDS)
.chain(["level", "layer"].iter())
.copied()
.collect();
Expand All @@ -433,15 +467,74 @@ fn parse_metadata(
(Some(metadata), Vec::new())
}

fn metadata_tokens(content: &str) -> Vec<(&str, &str)> {
let bytes = content.as_bytes();
let mut fields = Vec::new();
let mut index = 0;
while index < bytes.len() {
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
let name_start = index;
while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'-')
{
index += 1;
}
if name_start == index || index >= bytes.len() || bytes[index] != b'=' {
break;
}
let name = &content[name_start..index];
index += 1;
let value_start = index;
let mut next_field = bytes.len();
let mut cursor = index;
while cursor < bytes.len() {
if bytes[cursor].is_ascii_whitespace() {
let mut candidate = cursor;
while candidate < bytes.len() && bytes[candidate].is_ascii_whitespace() {
candidate += 1;
}
let candidate_start = candidate;
while candidate < bytes.len()
&& (bytes[candidate].is_ascii_alphanumeric() || bytes[candidate] == b'-')
{
candidate += 1;
}
if candidate_start < candidate
&& candidate < bytes.len()
&& bytes[candidate] == b'='
{
next_field = cursor;
break;
}
}
cursor += 1;
}
fields.push((name, content[value_start..next_field].trim()));
index = next_field;
}
fields
}

fn normalize_metadata(
mut metadata: BTreeMap<String, String>,
path: &str,
line: u32,
) -> (BTreeMap<String, String>, Option<Diagnostic>) {
let mut migrated = false;
if metadata.get("kind").is_some_and(|value| value == "req") {
metadata.insert("kind".to_owned(), "requirement".to_owned());
migrated = true;
if let Some(kind) = metadata.get("kind").cloned() {
let canonical = match kind.as_str() {
"req" => Some("requirement"),
"arch" => Some("architecture"),
"def" => Some("definition"),
"verif" => Some("verification"),
"info" => Some("information"),
_ => None,
};
if let Some(canonical) = canonical {
metadata.insert("kind".to_owned(), canonical.to_owned());
migrated = true;
}
}
if let Some(value) = metadata.remove("level") {
metadata.insert("modality".to_owned(), value);
Expand All @@ -458,6 +551,15 @@ fn normalize_metadata(
metadata.insert("status".to_owned(), "active".to_owned());
migrated = true;
}
if let Some(superseding_id) = metadata
.get("status")
.and_then(|value| value.strip_prefix("superseded-by="))
.map(str::to_owned)
{
metadata.insert("status".to_owned(), "superseded".to_owned());
metadata.insert("superseded-by".to_owned(), superseding_id);
migrated = true;
}
let diagnostic = migrated.then(|| {
diagnostic(
"NDF-MIG-001",
Expand Down
7 changes: 6 additions & 1 deletion crates/ndf-compiler/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ pub fn validate_graph(graph: &Graph, policy: &ValidationPolicy) -> Vec<Diagnosti
}

for node in &graph.nodes {
if node.modality == Some(Modality::Must) && !contains_word(&node.body, "MUST") {
if matches!(
node.kind,
NodeKind::Requirement | NodeKind::Constraint | NodeKind::Verification
) && node.modality == Some(Modality::Must)
&& !contains_word(&node.body, "MUST")
{
diagnostics.push(node_diagnostic(
"NDF-MOD-001",
format!("must requirement {} does not contain MUST", node.id),
Expand Down
97 changes: 97 additions & 0 deletions crates/ndf-compiler/tests/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use ndf_compiler::manifest::ProjectManifest;
use ndf_compiler::markdown::parse_markdown;
use ndf_core::model::Graph;
use serde_json::Value;
use tempfile::tempdir;

fn repository() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
Expand Down Expand Up @@ -58,3 +59,99 @@ fn invalid_and_legacy_markdown_match_the_frozen_contract() {

assert_eq!(actual, expected);
}

#[test]
fn davincioo_legacy_metadata_preserves_attributes_and_spaced_provenance() {
let directory = tempdir().unwrap();
let path = directory.path().join("legacy-davincioo.md");
fs::write(
&path,
r#"---
doc_id: DOC-DAVINCIOO-LEGACY
status: active
authority: normative
owner: core
---

# Legacy DavinciOO

## Parent {#PIPE-001}
<!-- ndf: kind=req level=must layer=L1 status=stable since=0.3 -->

The parent MUST exist.

## Mechanism {#PIPE-010}
<!-- ndf: kind=arch level=must layer=L2 status=stable since=0.3 author=ndf-projection unit=entries couples-with=PIPE-001 derived-from=docs/layouts/shape-m16 for A C.svg -->

The mechanism MUST preserve provenance.

## Legacy definition {#DEF-001}
<!-- ndf: kind=def level=must layer=L1 status=stable since=0.1 -->

This term MUST retain its definition kind.

## Superseded contract {#PIPE-020}
<!-- ndf: kind=req level=must layer=L1 status=superseded-by=PIPE-010 since=0.1 -->

This contract MUST remain traceable after supersession.
"#,
)
.unwrap();
let manifest = ProjectManifest::for_test(
"davincioo",
directory.path(),
["DEF", "DOC", "PIPE"],
["core"],
);

let parsed = parse_markdown(&path, &manifest).unwrap();
let mechanism = parsed
.nodes
.iter()
.find(|node| node.id.to_string() == "ndf://davincioo/PIPE-010")
.unwrap();

assert_eq!(mechanism.kind, ndf_core::model::NodeKind::Architecture);
assert_eq!(mechanism.attributes.get("since").unwrap(), "0.3");
assert_eq!(
mechanism.attributes.get("author").unwrap(),
"ndf-projection"
);
assert_eq!(mechanism.attributes.get("unit").unwrap(), "entries");
assert_eq!(
mechanism.attributes.get("derived-from").unwrap(),
"docs/layouts/shape-m16 for A C.svg"
);
assert!(parsed.edges.iter().any(|edge| {
edge.edge_type == "couples-with"
&& edge.source.to_string() == "ndf://davincioo/PIPE-010"
&& edge.target.to_string() == "ndf://davincioo/PIPE-001"
}));
let definition = parsed
.nodes
.iter()
.find(|node| node.id.to_string() == "ndf://davincioo/DEF-001")
.unwrap();
assert_eq!(definition.kind, ndf_core::model::NodeKind::Definition);
let superseded = parsed
.nodes
.iter()
.find(|node| node.id.to_string() == "ndf://davincioo/PIPE-020")
.unwrap();
assert_eq!(
superseded.status,
ndf_core::model::LifecycleStatus::Superseded
);
assert_eq!(
superseded.attributes.get("superseded-by").unwrap(),
"PIPE-010"
);
assert_eq!(
parsed
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "NDF-MIG-001")
.count(),
4
);
}
17 changes: 17 additions & 0 deletions crates/ndf-compiler/tests/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ fn must_and_information_language_are_checked_in_both_directions() {
assert_eq!(codes(&graph), ["NDF-MOD-001", "NDF-MOD-002"]);
}

#[test]
fn must_keyword_rule_applies_only_to_normative_statement_kinds() {
let mut definition = requirement("A CELL is one tile register.", Some("core"));
definition.id = NodeId::new("fixture", "DEF-CELL-001").unwrap();
definition.kind = NodeKind::Definition;
let mut architecture = requirement("The core has four PEs.", Some("core"));
architecture.id = NodeId::new("fixture", "ARCH-TOP-001").unwrap();
architecture.kind = NodeKind::Architecture;
let graph = Graph {
nodes: vec![definition, architecture],
edges: Vec::new(),
diagnostics: Vec::new(),
};

assert_eq!(codes(&graph), Vec::<String>::new());
}

#[test]
fn lifecycle_and_owner_invariants_are_enforced() {
let mut node = requirement("The pipeline MUST issue in order.", None);
Expand Down
Loading