diff --git a/Cargo.lock b/Cargo.lock index 618d3c16..a43d2e38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3824,6 +3824,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strictdoc-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87844e382e636cd1b44888b5c06c8644f806d6b697bf39bbaad500b08ac75e" + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -4431,6 +4437,7 @@ dependencies = [ "rust-mcp-sdk", "serde", "serde_json", + "strictdoc-parser", "strsim", "styx-embed", "tantivy", @@ -4448,6 +4455,7 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "walkdir", ] [[package]] @@ -4508,6 +4516,7 @@ dependencies = [ "marq", "pulldown-cmark", "rayon", + "strictdoc-parser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44cb6529..fda8a938 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,6 +154,9 @@ tantivy = "0.22" marq = { version = "2.2.0", features = ["all-handlers", "lang-vixen"] } pulldown-cmark = "0.13" +# StrictDoc parser for .sdoc spec files +strictdoc-parser = "0.1.1" + # CLI/runtime dependencies indoc = "2" urlencoding = "2.1" diff --git a/README.md b/README.md index e9f0f11b..1de430c7 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ > **Note:** Looking for Tracy, the frame profiler? That's a different project: [wolfpld/tracy](https://github.com/wolfpld/tracy) -Spec coverage for codebases. Tracks traceability between requirements (in markdown) and implementations/tests (in source code). Catches spec drift before it becomes a problem. +Spec coverage for codebases. Tracks traceability between requirements (in markdown or StrictDoc) and implementations/tests (in source code). Catches spec drift before it becomes a problem. ## What it does Specs, implementations, and tests drift apart — code changes without updating specs, specs describe unimplemented features, tests cover different scenarios than requirements specify. -Tracey uses lightweight annotations in markdown and source code comments to link specification requirements with implementing code and tests. This enables: +Tracey uses lightweight annotations in source code comments to link specification requirements — written in markdown or StrictDoc (`.sdoc`) — with the implementing code and tests. This enables: - Verifying multiple implementations (different languages, platforms) match the same spec - Finding which requirements lack implementation or tests @@ -32,9 +32,9 @@ Pre-built binaries are available for `aarch64-apple-darwin`, `aarch64-unknown-li ## Quick Start -### 1. Define requirements in your spec (markdown) +### 1. Define requirements in your spec -Use the `r[req.id]` syntax to define requirements in your specification documents: +Use the `r[req.id]` syntax to define requirements in a markdown specification document: ```markdown # Channel Management @@ -48,6 +48,8 @@ Client-initiated channels MUST use odd IDs, server-initiated channels MUST use e The prefix (`r` in this case) can be any lowercase alphanumeric marker. Tracey infers it from the spec files. +Specs authored in [StrictDoc](https://strictdoc.readthedocs.io/) (`.sdoc`) are loaded the same way — see [Writing Specs](docs/content/guide/writing-specs.md) for the syntax. Pick whichever format fits your project; they don't mix per spec. + ### 2. Reference requirements in your code Add references in source code comments using `PREFIX[VERB REQ]`: diff --git a/crates/tracey-core/Cargo.toml b/crates/tracey-core/Cargo.toml index 42602626..cf8ea7a3 100644 --- a/crates/tracey-core/Cargo.toml +++ b/crates/tracey-core/Cargo.toml @@ -59,6 +59,7 @@ facet = { workspace = true } eyre = { workspace = true } marq = { workspace = true } pulldown-cmark = { workspace = true } +strictdoc-parser = { workspace = true } # Optional ignore = { workspace = true, optional = true } diff --git a/crates/tracey-core/src/code_units.rs b/crates/tracey-core/src/code_units.rs index c772221c..e75b5e9f 100644 --- a/crates/tracey-core/src/code_units.rs +++ b/crates/tracey-core/src/code_units.rs @@ -1666,6 +1666,16 @@ fn extract_full_refs_from_text( warnings: &mut Vec, ) { let code_mask = crate::markdown::markdown_code_mask(text); + extract_full_relation_annotations_from_text( + text, + line, + base_offset, + file_code_mask, + &code_mask, + refs, + warnings, + ); + let mut chars = text.char_indices().peekable(); let mut prev_ch: Option = None; @@ -1741,6 +1751,74 @@ fn extract_full_refs_from_text( } } +/// Scan `text` for StrictDoc-style `@relation(...)` annotations and emit one +/// [`FullReqRef`] per UID in each annotation. Multi-UID annotations share the +/// call's span. `role=Refines` and unknown roles emit a warning and produce no +/// references. +fn extract_full_relation_annotations_from_text( + text: &str, + line: LineNumber, + base_offset: ByteOffset, + file_code_mask: &[bool], + code_mask: &[bool], + refs: &mut Vec, + warnings: &mut Vec, +) { + let mut search_start = 0; + while let Some(rel) = text[search_start..].find("@relation") { + let hit_start = search_start + rel; + + if crate::markdown::is_code_index(hit_start, code_mask) + || crate::markdown::is_code_index(base_offset.as_usize() + hit_start, file_code_mask) + { + search_start = hit_start + "@relation".len(); + continue; + } + + let Some(ann) = strictdoc_parser::parse_relation_annotation(&text[hit_start..]) else { + search_start = hit_start + "@relation".len(); + continue; + }; + + let abs_start = hit_start + ann.start; + let abs_end = hit_start + ann.end; + + let verb = match ann.role { + None | Some(strictdoc_parser::RelationRole::Implements) => "impl", + Some(strictdoc_parser::RelationRole::Verifies) => "verify", + Some(strictdoc_parser::RelationRole::Refines) + | Some(strictdoc_parser::RelationRole::Other(_)) => { + let location = RefLocation::from_relative_indices( + line, + base_offset, + abs_start, + abs_end.saturating_sub(1), + ); + warnings.push(location.into_warning()); + search_start = abs_end; + continue; + } + }; + + let location = RefLocation::from_relative_indices( + line, + base_offset, + abs_start, + abs_end.saturating_sub(1), + ); + + for uid in &ann.uids { + if let Some(rule_id) = parse_rule_id(uid) { + refs.push(location.into_full_ref("r".to_string(), verb.to_string(), rule_id)); + } else { + warnings.push(location.into_warning()); + } + } + + search_start = abs_end; + } +} + enum ParsedFullRef { Parsed { verb: String, @@ -1756,9 +1834,9 @@ enum ParsedFullRef { fn try_parse_full_ref( chars: &mut std::iter::Peekable>, ) -> Option { - // First char must be lowercase letter + // First char must be an ASCII letter. Case is preserved (StrictDoc UIDs). let first_char = chars.peek().map(|(_, c)| *c)?; - if !first_char.is_ascii_lowercase() { + if !first_char.is_ascii_alphabetic() { return None; } @@ -1766,13 +1844,13 @@ fn try_parse_full_ref( first_word.push(first_char); chars.next(); - // Read the first word + // Read the first word (could be verb or start of rule ID) let mut end_idx = 0; while let Some(&(idx, c)) = chars.peek() { end_idx = idx; if c == ']' || c == ' ' { break; - } else if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '+' { + } else if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '+' { first_word.push(c); chars.next(); } else { @@ -1791,9 +1869,9 @@ fn try_parse_full_ref( // Read the requirement ID let mut req_id = String::new(); - // First char must be lowercase + // First char of rule ID must be an ASCII letter (case preserved). if let Some(&(_, c)) = chars.peek() { - if c.is_ascii_lowercase() { + if c.is_ascii_alphabetic() { req_id.push(c); chars.next(); } else { @@ -1806,8 +1884,7 @@ fn try_parse_full_ref( if c == ']' { chars.next(); break; - } else if c.is_ascii_lowercase() - || c.is_ascii_digit() + } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '+' @@ -1851,9 +1928,9 @@ fn try_parse_full_ref( fn try_parse_req_ref( chars: &mut std::iter::Peekable>, ) -> Option { - // First char must be lowercase letter + // First char must be an ASCII letter. Case is preserved. let first_char = chars.peek().map(|(_, c)| *c)?; - if !first_char.is_ascii_lowercase() { + if !first_char.is_ascii_alphabetic() { return None; } @@ -1865,7 +1942,7 @@ fn try_parse_req_ref( while let Some(&(_, c)) = chars.peek() { if c == ']' || c == ' ' { break; - } else if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.' || c == '+' { + } else if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '+' { first_word.push(c); chars.next(); } else { @@ -1884,9 +1961,9 @@ fn try_parse_req_ref( // Read the requirement ID let mut req_id = String::new(); - // First char must be lowercase + // First char of rule ID must be an ASCII letter (case preserved). if let Some(&(_, c)) = chars.peek() { - if c.is_ascii_lowercase() { + if c.is_ascii_alphabetic() { req_id.push(c); chars.next(); } else { @@ -1898,12 +1975,7 @@ fn try_parse_req_ref( if c == ']' { chars.next(); break; - } else if c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '-' - || c == '+' - || c == '.' - { + } else if c.is_ascii_alphanumeric() || c == '-' || c == '+' || c == '.' { req_id.push(c); chars.next(); } else { diff --git a/crates/tracey-core/src/lexer.rs b/crates/tracey-core/src/lexer.rs index a7f73174..a44d63c4 100644 --- a/crates/tracey-core/src/lexer.rs +++ b/crates/tracey-core/src/lexer.rs @@ -413,9 +413,11 @@ fn extract_references_from_text( let mut first_word = String::new(); let mut valid = true; - // First char must be lowercase letter + // First char must be an ASCII letter. Case is preserved: lowercase + // first words are still tried as a verb (the only verbs are lowercase); + // uppercase or mixed-case first words flow through as a rule-ID body. if let Some(&(_, first_char)) = chars.peek() { - if first_char.is_ascii_lowercase() { + if first_char.is_ascii_alphabetic() { first_word.push(first_char); chars.next(); } else { @@ -431,12 +433,7 @@ fn extract_references_from_text( while let Some(&(_, c)) = chars.peek() { if c == ']' || c == ' ' { break; - } else if c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '-' - || c == '.' - || c == '+' - { + } else if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '+' { first_word.push(c); chars.next(); } else { @@ -461,9 +458,10 @@ fn extract_references_from_text( // Now read the rule ID let mut req_id = String::new(); - // First char of rule ID must be lowercase letter + // First char of rule ID must be an ASCII letter. + // Case is preserved (StrictDoc-style UIDs like BR-001). if let Some(&(_, c)) = chars.peek() { - if c.is_ascii_lowercase() { + if c.is_ascii_alphabetic() { req_id.push(c); chars.next(); } else { @@ -478,11 +476,7 @@ fn extract_references_from_text( if c == ']' { chars.next(); break; - } else if c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '-' - || c == '+' - || c == '.' + } else if c.is_ascii_alphanumeric() || c == '-' || c == '+' || c == '.' { req_id.push(c); chars.next(); @@ -576,6 +570,97 @@ fn extract_references_from_text( prev_ch = Some(ch); } } + + extract_relation_annotations(path, text, text_offset, base_line, file_code_mask, reqs); +} + +/// Scan `text` for StrictDoc-style `@relation(UID[, UID...][, scope=...][, role=...])` +/// annotations and emit a [`ReqReference`] for each UID. +/// +/// Multi-UID annotations expand to one reference per UID, all sharing the call's span. +/// `role=Refines` and unknown roles emit a parse warning and produce no references. +#[cfg(not(feature = "reverse"))] +fn extract_relation_annotations( + path: &Path, + text: &str, + text_offset: ByteOffset, + base_line: LineNumber, + file_code_mask: &[bool], + reqs: &mut Reqs, +) { + let code_mask = crate::markdown::markdown_code_mask(text); + let mut search_start = 0; + while let Some(rel) = text[search_start..].find("@relation") { + let hit_start = search_start + rel; + + // Honour code-mask: ignore `@relation` inside inline code spans or + // fenced code blocks, same as `r[...]` markers. + if crate::markdown::is_code_index(hit_start, &code_mask) + || crate::markdown::is_code_index(text_offset.as_usize() + hit_start, file_code_mask) + { + search_start = hit_start + "@relation".len(); + continue; + } + + let Some(ann) = strictdoc_parser::parse_relation_annotation(&text[hit_start..]) else { + search_start = hit_start + "@relation".len(); + continue; + }; + + let abs_start = hit_start + ann.start; + let abs_end = hit_start + ann.end; + + let verb = match ann.role { + None | Some(strictdoc_parser::RelationRole::Implements) => RefVerb::Impl, + Some(strictdoc_parser::RelationRole::Verifies) => RefVerb::Verify, + Some(strictdoc_parser::RelationRole::Refines) + | Some(strictdoc_parser::RelationRole::Other(_)) => { + let location = RefLocation::from_relative_indices( + base_line, + text_offset, + abs_start, + abs_end.saturating_sub(1), + ); + reqs.warnings.push(ParseWarning { + file: path.to_path_buf(), + line: location.line().as_usize(), + span: location.span().into(), + kind: WarningKind::MalformedReference, + }); + search_start = abs_end; + continue; + } + }; + + let location = RefLocation::from_relative_indices( + base_line, + text_offset, + abs_start, + abs_end.saturating_sub(1), + ); + + for uid in &ann.uids { + if let Some(rule_id) = parse_rule_id(uid) { + reqs.references.push(ReqReference { + prefix: "r".to_string(), + verb, + req_id: rule_id, + file: path.to_path_buf(), + line: location.line().as_usize(), + span: location.span().into(), + }); + } else { + reqs.warnings.push(ParseWarning { + file: path.to_path_buf(), + line: location.line().as_usize(), + span: location.span().into(), + kind: WarningKind::MalformedReference, + }); + } + } + + search_start = abs_end; + } } // r[impl ref.syntax.req-id+3] @@ -897,4 +982,117 @@ mod tests { assert_eq!(reqs.references[0].verb, RefVerb::Impl); assert_eq!(reqs.references[0].req_id, "foo.bar"); } + + #[test] + fn test_uppercase_rule_id_in_marker_is_preserved() { + let content = "// r[impl BR-001]\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs.references[0].req_id, "BR-001"); + assert_eq!(reqs.references[0].verb, RefVerb::Impl); + } + + #[test] + fn test_mixed_case_rule_id_in_marker_is_preserved() { + let content = "// r[verify Auth.Login]\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs.references[0].req_id, "Auth.Login"); + assert_eq!(reqs.references[0].verb, RefVerb::Verify); + } + + #[test] + fn test_uppercase_rule_id_no_verb_defaults_to_impl() { + let content = "// r[BR-001]\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs.references[0].req_id, "BR-001"); + assert_eq!(reqs.references[0].verb, RefVerb::Impl); + } + + #[test] + fn test_relation_annotation_single_uid() { + let content = "// @relation(BR-001, scope=function)\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs.references[0].req_id, "BR-001"); + assert_eq!(reqs.references[0].verb, RefVerb::Impl); + assert_eq!(reqs.references[0].prefix, "r"); + } + + #[test] + fn test_relation_annotation_role_verifies() { + let content = "// @relation(BR-001, role=Verifies)\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs.references[0].verb, RefVerb::Verify); + } + + #[test] + fn test_relation_annotation_multi_uid_shares_span() { + let content = "// @relation(BR-001, BR-002, scope=function, role=Verifies)\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 2); + assert_eq!(reqs.references[0].req_id, "BR-001"); + assert_eq!(reqs.references[1].req_id, "BR-002"); + assert_eq!(reqs.references[0].verb, RefVerb::Verify); + assert_eq!(reqs.references[1].verb, RefVerb::Verify); + // Both annotations share the same span (the @relation call) + assert_eq!( + reqs.references[0].span.offset, + reqs.references[1].span.offset + ); + assert_eq!( + reqs.references[0].span.length, + reqs.references[1].span.length + ); + } + + #[test] + fn test_relation_annotation_refines_warns_and_skips() { + let content = "// @relation(BR-001, role=Refines)\nfn f() {}\n"; + let reqs = Reqs::extract_from_content(Path::new("test.rs"), content); + assert_eq!(reqs.len(), 0, "Refines role must not produce a reference"); + assert_eq!(reqs.warnings.len(), 1); + assert!(matches!( + reqs.warnings[0].kind, + WarningKind::MalformedReference + )); + } + + #[test] + fn test_fixture_strictdoc_lib_rs_yields_all_refs() { + // Mirror of the integration fixture so any drift in lexer is caught here. + let content = "// Implementation site: @relation, no explicit role -> Implements. +// @relation(BR-001, scope=function) +pub fn connect() {} + +// Test site: explicit Verifies role. +// @relation(BR-002, scope=function, role=Verifies) +#[test] +fn test_heartbeat_emitted() {} + +// Multi-UID annotation; both UIDs share the same span. +// @relation(BR-001, BR-002, scope=function, role=Verifies) +fn dual_verify() {} + +// Refines role is rejected for v1: warning, no reference produced. +// @relation(BR-003, role=Refines) +fn refines_placeholder() {} + +// Legacy r[...] marker uses the same prefix and continues to work alongside @relation. +// r[impl BR-003] +pub fn reconnect() {} +"; + let reqs = Reqs::extract_from_content(Path::new("src/lib.rs"), content); + let ids: Vec = reqs + .references + .iter() + .map(|r| format!("{:?} {}", r.verb, r.req_id)) + .collect(); + // 1 impl BR-001 + 1 verify BR-002 + 2 verify (BR-001, BR-002) + 1 impl BR-003 = 5 refs + assert_eq!(reqs.len(), 5, "expected 5 references, got: {ids:?}"); + // Refines produces a warning + assert_eq!(reqs.warnings.len(), 1); + } } diff --git a/crates/tracey-core/src/lib.rs b/crates/tracey-core/src/lib.rs index a420d8e4..19ecca08 100644 --- a/crates/tracey-core/src/lib.rs +++ b/crates/tracey-core/src/lib.rs @@ -22,7 +22,7 @@ pub use rule_id::{ parse_rule_id, }; pub use sources::{ - ExtractionResult, MemorySources, PathSources, SUPPORTED_EXTENSIONS, Sources, + ExtractionResult, MemorySources, PathSources, SUPPORTED_EXTENSIONS, Sources, is_spec_extension, is_supported_extension, }; pub use spec::ReqDefinition; diff --git a/crates/tracey-core/src/sources.rs b/crates/tracey-core/src/sources.rs index 5486d56b..ecfdc096 100644 --- a/crates/tracey-core/src/sources.rs +++ b/crates/tracey-core/src/sources.rs @@ -90,6 +90,13 @@ pub fn is_supported_extension(ext: &OsStr) -> bool { .unwrap_or(false) } +/// Check if a file extension identifies a specification source tracey can load. +/// +/// Currently `md` (marq markdown) and `sdoc` (StrictDoc). +pub fn is_spec_extension(ext: &OsStr) -> bool { + matches!(ext.to_str(), Some("md") | Some("sdoc")) +} + /// Trait for providing source files to extract requirements from pub trait Sources { /// Extract requirements from all sources diff --git a/crates/tracey/Cargo.toml b/crates/tracey/Cargo.toml index ae588f6e..3f5f13bd 100644 --- a/crates/tracey/Cargo.toml +++ b/crates/tracey/Cargo.toml @@ -58,6 +58,9 @@ globset = { workspace = true } # Markdown rendering with syntax highlighting and diagrams marq = { workspace = true } +# StrictDoc (.sdoc) spec parser +strictdoc-parser = { workspace = true } + # Syntax highlighting for source files arborium = { workspace = true } @@ -114,3 +117,4 @@ time = { workspace = true, features = ["formatting"] } [dev-dependencies] tempfile = { workspace = true } roam-core = { workspace = true } +walkdir = "2" diff --git a/crates/tracey/build.rs b/crates/tracey/build.rs index 49d8159e..551da80d 100644 --- a/crates/tracey/build.rs +++ b/crates/tracey/build.rs @@ -262,7 +262,7 @@ fn build_dashboard() { } // Check if pnpm is available - let pnpm_check = shell_command("pnpm").arg("version").output(); + let pnpm_check = shell_command("pnpm").arg("--version").output(); match pnpm_check { Ok(output) if output.status.success() => { diff --git a/crates/tracey/src/bridge/http/dashboard/pnpm-workspace.yaml b/crates/tracey/src/bridge/http/dashboard/pnpm-workspace.yaml new file mode 100644 index 00000000..24a72cab --- /dev/null +++ b/crates/tracey/src/bridge/http/dashboard/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@parcel/watcher': true + esbuild: true diff --git a/crates/tracey/src/bridge/lsp.rs b/crates/tracey/src/bridge/lsp.rs index 84874e06..5073bd1e 100644 --- a/crates/tracey/src/bridge/lsp.rs +++ b/crates/tracey/src/bridge/lsp.rs @@ -733,7 +733,9 @@ impl Backend { continue; } let should_clear = path.extension().is_some_and(|ext| { - ext == "md" || ext == "styx" || tracey_core::is_supported_extension(ext) + tracey_core::is_spec_extension(ext) + || ext == "styx" + || tracey_core::is_supported_extension(ext) }); if !should_clear { continue; diff --git a/crates/tracey/src/bump.rs b/crates/tracey/src/bump.rs index 06799f37..6a3948e2 100644 --- a/crates/tracey/src/bump.rs +++ b/crates/tracey/src/bump.rs @@ -71,8 +71,20 @@ pub fn git_cat_file(project_root: &Path, revision: &str, path: &str) -> Result Result> { +/// Parse a spec file's contents (markdown or `.sdoc`) and return a map from +/// rule **base** ID → `ReqDefinition`. +async fn parse_spec_rules( + content: &str, + path: &str, +) -> Result> { + if path.ends_with(".sdoc") { + let rules = crate::sdoc::extract_rules_from_sdoc(content, path).await?; + return Ok(rules + .into_iter() + .map(|r| (r.def.id.base.clone(), r.def)) + .collect()); + } + let doc = render(content, &RenderOptions::default()) .await .map_err(|e| eyre::eyre!("failed to parse spec: {e}"))?; @@ -143,10 +155,10 @@ pub async fn detect_changed_rules( }; let old_rules = match old_content { - Some(ref c) => parse_spec_rules(c).await?, + Some(ref c) => parse_spec_rules(c, staged_file).await?, None => HashMap::new(), // new file }; - let new_rules = parse_spec_rules(&new_content).await?; + let new_rules = parse_spec_rules(&new_content, staged_file).await?; for (base, new_req) in &new_rules { let Some(old_req) = old_rules.get(base) else { diff --git a/crates/tracey/src/data.rs b/crates/tracey/src/data.rs index 956c33b3..65b0ed5d 100644 --- a/crates/tracey/src/data.rs +++ b/crates/tracey/src/data.rs @@ -830,7 +830,11 @@ fn full_walk_for_roots( if !ft.is_file() { continue; } - if include_markdown_only && path.extension().is_none_or(|ext| ext != "md") { + if include_markdown_only + && path + .extension() + .is_none_or(|ext| !tracey_core::is_spec_extension(ext)) + { continue; } if include_supported_ext_only @@ -864,7 +868,9 @@ fn update_cached_scan_paths( for changed in changed_files { let exists = changed.exists(); let ext_ok = if include_markdown_only { - changed.extension().is_some_and(|ext| ext == "md") + changed + .extension() + .is_some_and(tracey_core::is_spec_extension) } else if include_supported_ext_only { changed.extension().is_some_and(is_supported_extension) } else { @@ -1067,6 +1073,90 @@ async fn extract_markdown_rules_cached( Ok(extracted) } +async fn extract_sdoc_rules_cached( + project_root: &Path, + path: &Path, + overlay: &FileOverlay, + cache: &mut BuildCache, + quiet: bool, + stats: &mut CacheStats, +) -> Result> { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let overlay_content = overlay + .get(path) + .or_else(|| overlay.get(&canonical)) + .cloned(); + let overlay_is_present = overlay_content.is_some(); + + let (content, file_len, modified_nanos) = if let Some(content) = overlay_content { + (content.clone(), content.len() as u64, None) + } else { + let metadata = tokio::fs::metadata(&canonical).await.ok(); + let file_len = metadata.as_ref().map_or(0, std::fs::Metadata::len); + let modified_nanos = metadata + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(file_modified_nanos); + ( + read_file_with_overlay(&canonical, overlay).await?, + file_len, + modified_nanos, + ) + }; + + let content_hash = compute_content_hash(&content); + if let Some(entry) = cache.markdown_files.get(&canonical) { + if !overlay_is_present + && entry.file_len == file_len + && entry.modified_nanos == modified_nanos + { + stats.metadata_hits += 1; + return Ok(entry.extracted_rules.clone()); + } + if entry.content_hash == content_hash { + let updated = CachedMarkdownFile { + content_hash, + file_len, + modified_nanos, + extracted_rules: entry.extracted_rules.clone(), + }; + cache.markdown_files.insert(canonical, updated.clone()); + stats.hash_hits += 1; + return Ok(updated.extracted_rules); + } + } + + let relative_display = if let Ok(rel) = canonical.strip_prefix(project_root) { + rel.display().to_string() + } else { + compute_relative_path(project_root, &canonical) + }; + + let extracted = crate::sdoc::extract_rules_from_sdoc(&content, &relative_display).await?; + + if !quiet && !extracted.is_empty() { + eprintln!( + " {} {} requirements from {}", + "Found".green(), + extracted.len(), + relative_display + ); + } + + cache.markdown_files.insert( + canonical, + CachedMarkdownFile { + content_hash, + file_len, + modified_nanos, + extracted_rules: extracted.clone(), + }, + ); + stats.misses += 1; + stats.reparsed += 1; + Ok(extracted) +} + async fn load_rules_from_includes_cached( project_root: &Path, include_patterns: &[String], @@ -1080,7 +1170,10 @@ async fn load_rules_from_includes_cached( get_cached_spec_scan_paths(project_root, include_patterns, changed_files, cache); let (spec_roots, _) = build_scan_roots(project_root, include_patterns); for overlay_path in overlay.keys() { - if overlay_path.extension().is_none_or(|ext| ext != "md") { + if overlay_path + .extension() + .is_none_or(|ext| !tracey_core::is_spec_extension(ext)) + { continue; } if path_matches_any_root(overlay_path, &spec_roots) { @@ -1092,8 +1185,15 @@ async fn load_rules_from_includes_cached( let mut seen_ids: BTreeSet = BTreeSet::new(); let collected_paths: Vec = spec_paths.into_iter().collect(); for path in &collected_paths { - let extracted = - extract_markdown_rules_cached(project_root, path, overlay, cache, quiet, stats).await?; + let extracted = if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "sdoc") + { + extract_sdoc_rules_cached(project_root, path, overlay, cache, quiet, stats).await? + } else { + extract_markdown_rules_cached(project_root, path, overlay, cache, quiet, stats).await? + }; for rule in extracted { let id = rule.def.id.to_string(); if seen_ids.contains(&id) { diff --git a/crates/tracey/src/lib.rs b/crates/tracey/src/lib.rs index bb76bb7d..99dc8e57 100644 --- a/crates/tracey/src/lib.rs +++ b/crates/tracey/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod daemon; pub mod data; pub(crate) mod rule_suggestions; +pub mod sdoc; pub mod search; pub mod server; pub mod vite; @@ -100,7 +101,7 @@ pub async fn load_rules_from_glob( })?; let effective = if remaining_parts.is_empty() { - "**/*.md".to_string() + "**/*.{md,sdoc}".to_string() } else { remaining_parts.join("/") }; @@ -125,10 +126,17 @@ pub async fn load_rules_from_glob( let entry = entry?; let path = entry.path(); - // Only process .md files - if path.extension().is_none_or(|ext| ext != "md") { + // Only process supported spec files (.md, .sdoc) + if path + .extension() + .is_none_or(|ext| !tracey_core::is_spec_extension(ext)) + { continue; } + let is_sdoc = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "sdoc"); // Check if the path matches the glob pattern let relative = path.strip_prefix(&walk_root).unwrap_or(path); @@ -148,10 +156,34 @@ pub async fn load_rules_from_glob( continue; } - // Read and render markdown to extract rules with HTML let content = std::fs::read_to_string(path) .wrap_err_with(|| format!("Failed to read {}", path.display()))?; + if is_sdoc { + let sdoc_rules = crate::sdoc::extract_rules_from_sdoc(&content, &display_path).await?; + if !sdoc_rules.is_empty() && !quiet { + eprintln!( + " {} {} requirements from {}", + "Found".green(), + sdoc_rules.len(), + display_path + ); + } + for extracted in sdoc_rules { + let req_id = extracted.def.id.to_string(); + if seen_ids.contains(&req_id) { + eyre::bail!( + "Duplicate requirement '{}' found in {}", + extracted.def.id.red(), + display_path + ); + } + seen_ids.insert(req_id); + rules.push(extracted); + } + continue; + } + let doc = render(&content, &RenderOptions::default()) .await .map_err(|e| eyre::eyre!("Failed to process {}: {}", path.display(), e))?; diff --git a/crates/tracey/src/sdoc.rs b/crates/tracey/src/sdoc.rs new file mode 100644 index 00000000..0fe3e3eb --- /dev/null +++ b/crates/tracey/src/sdoc.rs @@ -0,0 +1,116 @@ +//! Loader for StrictDoc (`.sdoc`) specification files. +//! +//! Bridges `strictdoc_parser::RequirementView` onto tracey's existing +//! [`crate::ExtractedRule`] shape so that downstream code paths (coverage, +//! daemon, queries) treat `.sdoc` and `.md` specs uniformly. + +use eyre::{Result, eyre}; +use marq::{RenderOptions, ReqDefinition, ReqMetadata, SourceSpan, parse_rule_id}; + +use crate::ExtractedRule; + +/// Synthetic marker prefix used for requirements loaded from `.sdoc` files. +/// +/// `.sdoc` has no `r[...]`-style marker, so this value is what +/// `@relation(...)` source markers must agree on for matching. +pub const SDOC_PREFIX: &str = "r"; + +/// Parse a `.sdoc` document and produce one [`ExtractedRule`] per +/// `[REQUIREMENT]` block. +pub async fn extract_rules_from_sdoc( + content: &str, + source_file: &str, +) -> Result> { + let doc = strictdoc_parser::parse(content) + .map_err(|e| eyre!("Failed to parse {} as StrictDoc: {}", source_file, e))?; + + let markup_is_markdown = doc + .options + .get("MARKUP") + .is_some_and(|v| v.eq_ignore_ascii_case("Markdown")); + + let mut rules = Vec::new(); + for view in doc.requirements_flat() { + let Some(uid) = view.uid() else { + continue; + }; + let Some(rule_id) = parse_rule_id(uid) else { + eprintln!( + "Warning: invalid UID '{}' in {}, skipping requirement", + uid, source_file + ); + continue; + }; + + let req_span = view.requirement.span; + let raw = content + .get(req_span.start..req_span.end) + .unwrap_or("") + .to_string(); + + let html = match view.statement() { + Some(stmt) if markup_is_markdown => marq::render(stmt, &RenderOptions::default()) + .await + .map(|d| d.html) + .unwrap_or_else(|_| wrap_paragraph(stmt)), + Some(stmt) => wrap_paragraph(stmt), + None => String::new(), + }; + + let anchor_id = format!("r--{}", uid); + let length = req_span.end.saturating_sub(req_span.start); + + let def = ReqDefinition { + id: rule_id, + anchor_id, + marker_span: SourceSpan { + offset: req_span.start, + length: 0, + }, + span: SourceSpan { + offset: req_span.start, + length, + }, + line: req_span.line as usize, + metadata: ReqMetadata::default(), + raw, + html, + }; + + let column = Some(compute_column(content, req_span.start)); + let section_title = view.section_path.last().map(|s| s.to_string()); + + rules.push(ExtractedRule { + def, + source_file: source_file.to_string(), + prefix: SDOC_PREFIX.to_string(), + column, + section: None, + section_title, + }); + } + Ok(rules) +} + +fn wrap_paragraph(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 8); + out.push_str("

"); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out.push_str("

"); + out +} + +fn compute_column(content: &str, byte_offset: usize) -> usize { + let before = &content[..byte_offset.min(content.len())]; + let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0); + before[line_start..].chars().count() + 1 +} diff --git a/crates/tracey/tests/fixtures-strictdoc/config.styx b/crates/tracey/tests/fixtures-strictdoc/config.styx new file mode 100644 index 00000000..0f61c784 --- /dev/null +++ b/crates/tracey/tests/fixtures-strictdoc/config.styx @@ -0,0 +1,14 @@ +// Tracey config for the StrictDoc fixture: .sdoc spec + @relation source markers. + +specs ( + { + name br + include (spec.sdoc) + impls ( + { + name rust + include (src/**/*.rs) + } + ) + } +) diff --git a/crates/tracey/tests/fixtures-strictdoc/spec.sdoc b/crates/tracey/tests/fixtures-strictdoc/spec.sdoc new file mode 100644 index 00000000..8003cb17 --- /dev/null +++ b/crates/tracey/tests/fixtures-strictdoc/spec.sdoc @@ -0,0 +1,24 @@ +[DOCUMENT] +TITLE: Bridge requirements + +OPTIONS: + MARKUP: Markdown + +[REQUIREMENT] +UID: BR-001 +TITLE: Connection setup +STATEMENT: >>> +The bridge **must** establish a connection within 5 seconds of startup. + +A retry policy is **out of scope** for this requirement. +<<< + +[REQUIREMENT] +UID: BR-002 +TITLE: Heartbeat +STATEMENT: The bridge MUST emit a heartbeat every 1000 milliseconds. + +[REQUIREMENT] +UID: BR-003 +TITLE: Reconnect +STATEMENT: The bridge MUST attempt reconnection on link loss. diff --git a/crates/tracey/tests/fixtures-strictdoc/src/lib.rs b/crates/tracey/tests/fixtures-strictdoc/src/lib.rs new file mode 100644 index 00000000..8e437fc5 --- /dev/null +++ b/crates/tracey/tests/fixtures-strictdoc/src/lib.rs @@ -0,0 +1,20 @@ +// Implementation site: @relation, no explicit role → Implements. +// @relation(BR-001, scope=function) +pub fn connect() {} + +// Test site: explicit Verifies role. +// @relation(BR-002, scope=function, role=Verifies) +#[test] +fn test_heartbeat_emitted() {} + +// Multi-UID annotation; both UIDs share the same span. +// @relation(BR-001, BR-002, scope=function, role=Verifies) +fn dual_verify() {} + +// Refines role is rejected for v1: warning, no reference produced. +// @relation(BR-003, role=Refines) +fn refines_placeholder() {} + +// Legacy r[...] marker uses the same prefix and continues to work alongside @relation. +// r[impl BR-003] +pub fn reconnect() {} diff --git a/crates/tracey/tests/sdoc_corpus_smoke.rs b/crates/tracey/tests/sdoc_corpus_smoke.rs new file mode 100644 index 00000000..a63c7d6d --- /dev/null +++ b/crates/tracey/tests/sdoc_corpus_smoke.rs @@ -0,0 +1,174 @@ +//! Optional smoke check: walk one or more checkouts of upstream StrictDoc +//! corpora and confirm the `.sdoc`→`ExtractedRule` bridge handles every +//! document without panicking and produces sensible counts. +//! +//! This test is `#[ignore]`d by default and reads corpus paths from the +//! `STRICTDOC_CORPUS` environment variable. Multiple paths may be passed, +//! separated by `:`. Run it with: +//! +//! ```text +//! STRICTDOC_CORPUS=/path/to/strictdoc:/path/to/reqmgmt \ +//! cargo test -p tracey --test sdoc_corpus_smoke -- --ignored --nocapture +//! ``` +//! +//! Known-good corpora: +//! - `github.com/strictdoc-project/strictdoc` — the parser's own test +//! suite (any tag from 0.21.0 onwards). +//! - `github.com/zephyrproject-rtos/reqmgmt` — Zephyr RTOS requirements +//! management corpus in StrictDoc format. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +#[derive(Default)] +struct Counts { + total: u32, + ok: u32, + err: u32, + total_rules: u64, + total_uidless: u64, + markup_markdown_docs: u64, + max_rules_in_doc: usize, + max_path: String, + errors: BTreeMap, +} + +#[tokio::test] +#[ignore] +async fn upstream_strictdoc_corpus_smoke() { + let Ok(roots_str) = std::env::var("STRICTDOC_CORPUS") else { + eprintln!( + "skipping: STRICTDOC_CORPUS env var not set; see test file docs" + ); + return; + }; + + let roots: Vec = roots_str + .split(':') + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + if roots.is_empty() { + eprintln!("skipping: STRICTDOC_CORPUS is empty after splitting on ':'"); + return; + } + + let mut overall = Counts::default(); + + for root in &roots { + if !root.exists() { + eprintln!("skipping: {} does not exist", root.display()); + continue; + } + eprintln!("\n=== Corpus: {} ===", root.display()); + let mut corpus = Counts::default(); + walk_corpus(root, &mut corpus, &mut overall).await; + report(&corpus); + } + + eprintln!("\n=== Overall ==="); + report(&overall); + + assert!( + overall.total > 100, + "expected at least one meaningful corpus, got total={}; check STRICTDOC_CORPUS paths", + overall.total + ); + // 95% bridge success across whatever corpora the caller pointed at. + assert!( + (overall.ok as u64) * 20 >= (overall.total as u64) * 19, + "expected ≥95% successful bridge calls, got {}/{}", + overall.ok, + overall.total + ); +} + +async fn walk_corpus(root: &Path, corpus: &mut Counts, overall: &mut Counts) { + for entry in WalkDir::new(root) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.file_type().is_file() { + continue; + } + if entry.path().extension().and_then(|e| e.to_str()) != Some("sdoc") { + continue; + } + corpus.total += 1; + overall.total += 1; + let display = entry + .path() + .strip_prefix(root) + .unwrap_or(entry.path()) + .display() + .to_string(); + let content = match std::fs::read_to_string(entry.path()) { + Ok(c) => c, + Err(_) => continue, + }; + + if content.contains("MARKUP: Markdown") { + corpus.markup_markdown_docs += 1; + overall.markup_markdown_docs += 1; + } + + match tracey::sdoc::extract_rules_from_sdoc(&content, &display).await { + Ok(rules) => { + corpus.ok += 1; + overall.ok += 1; + let n = rules.len(); + corpus.total_rules += n as u64; + overall.total_rules += n as u64; + if n > corpus.max_rules_in_doc { + corpus.max_rules_in_doc = n; + corpus.max_path = display.clone(); + } + if n > overall.max_rules_in_doc { + overall.max_rules_in_doc = n; + overall.max_path = display.clone(); + } + } + Err(e) => { + corpus.err += 1; + overall.err += 1; + let short = + e.to_string().lines().next().unwrap_or("").to_string(); + *corpus.errors.entry(short.clone()).or_insert(0) += 1; + *overall.errors.entry(short).or_insert(0) += 1; + } + } + + if let Ok(doc) = strictdoc_parser::parse(&content) { + for view in doc.requirements_flat() { + if view.uid().is_none() { + corpus.total_uidless += 1; + overall.total_uidless += 1; + } + } + } + } +} + +fn report(c: &Counts) { + eprintln!("Total .sdoc files visited: {}", c.total); + eprintln!(" Parsed OK by bridge: {}", c.ok); + eprintln!(" Failed in bridge: {}", c.err); + eprintln!("Total requirements via bridge: {}", c.total_rules); + eprintln!( + "Requirements without UID (skipped by bridge): {}", + c.total_uidless + ); + eprintln!( + "Max requirements in a single doc: {} ({})", + c.max_rules_in_doc, c.max_path + ); + eprintln!("Docs with MARKUP: Markdown: {}", c.markup_markdown_docs); + if !c.errors.is_empty() { + eprintln!("Error shapes:"); + for (msg, count) in &c.errors { + eprintln!(" [{count}x] {msg}"); + } + } +} diff --git a/crates/tracey/tests/sdoc_integration.rs b/crates/tracey/tests/sdoc_integration.rs new file mode 100644 index 00000000..db9f45c9 --- /dev/null +++ b/crates/tracey/tests/sdoc_integration.rs @@ -0,0 +1,134 @@ +//! End-to-end test for StrictDoc (`.sdoc`) spec loading + `@relation(...)` +//! source markers. + +use std::path::PathBuf; +use std::sync::Arc; + +mod common; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures-strictdoc") +} + +async fn create_engine() -> Arc { + let project_root = fixture_root(); + let config_path = project_root.join("config.styx"); + + Arc::new( + tracey::daemon::Engine::new(project_root, config_path) + .await + .expect("Failed to create engine"), + ) +} + +#[tokio::test] +async fn sdoc_spec_yields_uppercase_uids_and_html() { + let rules = tracey::load_rules_from_globs(&fixture_root(), &["spec.sdoc"], true) + .await + .expect("load_rules_from_globs must succeed for .sdoc fixture"); + + let ids: Vec = rules.iter().map(|r| r.def.id.to_string()).collect(); + assert!( + ids.contains(&"BR-001".to_string()), + "expected BR-001, got {ids:?}" + ); + assert!( + ids.contains(&"BR-002".to_string()), + "expected BR-002, got {ids:?}" + ); + assert!( + ids.contains(&"BR-003".to_string()), + "expected BR-003, got {ids:?}" + ); + + let br001 = rules + .iter() + .find(|r| r.def.id.to_string() == "BR-001") + .expect("BR-001 not found"); + assert!( + br001.def.html.contains(""), + "BR-001 declares MARKUP:Markdown; expected rendered , got: {}", + br001.def.html + ); + + let br002 = rules + .iter() + .find(|r| r.def.id.to_string() == "BR-002") + .expect("BR-002 not found"); + assert!( + br002.def.html.contains(""), + "BR-002 STATEMENT has no markdown emphasis" + ); + + for rule in &rules { + assert_eq!( + rule.prefix, "r", + "sdoc rules should expose synthetic prefix 'r'" + ); + } +} + +#[tokio::test] +async fn engine_status_covers_sdoc_rules() { + let engine = create_engine().await; + let service = tracey::daemon::TraceyService::new(engine); + let rpc_service = common::create_test_rpc_service(service).await; + + let status = rpc_service + .client + .status() + .await + .expect("status RPC must succeed"); + + let br = status + .impls + .iter() + .find(|i| i.spec == "br" && i.impl_name == "rust") + .unwrap_or_else(|| { + panic!( + "expected br/rust impl in status; got: {:?}", + status + .impls + .iter() + .map(|i| ( + &i.spec, + &i.impl_name, + i.total_rules, + i.covered_rules, + i.verified_rules + )) + .collect::>() + ) + }); + + // Three requirements parsed from spec.sdoc; one (BR-001) implemented via + // @relation(BR-001,...), one (BR-002) verified via role=Verifies, + // BR-001 also covered by the multi-uid annotation, BR-003 implemented via + // legacy r[impl BR-003]. + assert_eq!( + br.total_rules, 3, + "expected 3 rules from spec.sdoc; impl_status = total={} covered={} verified={}", + br.total_rules, br.covered_rules, br.verified_rules + ); + assert!( + br.covered_rules >= 2, + "expected at least BR-001 and BR-003 covered; impl_status = total={} covered={} verified={}", + br.total_rules, + br.covered_rules, + br.verified_rules + ); + assert!( + br.verified_rules >= 1, + "expected at least BR-002 verified; impl_status = total={} covered={} verified={}", + br.total_rules, + br.covered_rules, + br.verified_rules + ); +} diff --git a/docs/content/guide/annotating-code.md b/docs/content/guide/annotating-code.md index e4ecf223..93929fa9 100644 --- a/docs/content/guide/annotating-code.md +++ b/docs/content/guide/annotating-code.md @@ -128,6 +128,62 @@ Tracey extracts annotations from comments in all major languages via tree-sitter /* r[verify buffer.allocation] */ ``` +## StrictDoc-style markers (`@relation`) + +If your spec is authored in [StrictDoc](https://strictdoc.readthedocs.io/) — see [Writing Specs](writing-specs.md#strictdoc-format-sdoc) — tracey also recognises StrictDoc's `@relation(...)` annotation in source comments. The two syntaxes coexist freely; both produce references against the same spec. + +```rust +// @relation(CH-001, scope=function) +fn allocate_channel_id(&mut self) -> u32 { /* ... */ } +``` + +`scope=` is accepted (`function`, `file`, or `line`) and forwarded to StrictDoc tooling but is not used by tracey's matcher; it's safe to omit if you're tracey-only. + +### Role mapping + +The optional `role=` field selects the tracey verb: + +| StrictDoc role | Tracey verb | Notes | +|----------------|-------------|-------| +| (omitted) | `impl` | Default — same as `role=Implements` | +| `Implements` | `impl` | | +| `Verifies` | `verify` | | +| `Refines` | — | Not yet mapped; emits a warning and is skipped | + +```rust +// @relation(CH-001, role=Verifies) +#[test] +fn channels_are_sequential() { /* ... */ } +``` + +### Multiple UIDs in one annotation + +A single `@relation(...)` call can reference several UIDs: + +```rust +// @relation(CH-001, CH-002, scope=function, role=Verifies) +#[test] +fn id_allocation_and_parity_match_spec() { /* ... */ } +``` + +This expands to one reference per UID. All references share the same source span. + +### Interoperating with `r[…]` markers + +Within a single project you can mix `@relation(...)` and `r[…]` freely: + +```rust +// Legacy markdown-style marker: +// r[impl CH-001] +fn allocate_one() {} + +// StrictDoc-style equivalent: +// @relation(CH-002, scope=function) +fn allocate_another() {} +``` + +Both forms produce references that match the same spec rules. The `r[…]` form continues to work unchanged for projects that don't use StrictDoc. + ## Multiple annotations per function A single function can implement multiple requirements: diff --git a/docs/content/guide/writing-specs.md b/docs/content/guide/writing-specs.md index 81efc0fe..6051a4cf 100644 --- a/docs/content/guide/writing-specs.md +++ b/docs/content/guide/writing-specs.md @@ -156,3 +156,34 @@ These don't conflict because `r[api.format]` and `m[api.format]` belong to diffe ## Versioning Requirements can carry a version suffix like `r[auth.login+2]`. This is covered in detail in [Versioning](versioning.md). The short version: when you change a requirement's text, you bump its version number so tracey can tell you which code references are stale. + +## StrictDoc format (`.sdoc`) + +Tracey also reads [StrictDoc](https://strictdoc.readthedocs.io/) `.sdoc` files as a sibling to markdown. The format is picked from the file extension — there is no `format` field on `SpecConfig`. Mix `.md` and `.sdoc` files inside the same `include` glob freely; both end up in the same coverage table. + +Minimal example: + +```sdoc +[DOCUMENT] +TITLE: Channel management + +[REQUIREMENT] +UID: CH-001 +TITLE: Sequential ID allocation +STATEMENT: Channel IDs MUST be allocated sequentially starting from 0. + +[REQUIREMENT] +UID: CH-002 +TITLE: Parity rule +STATEMENT: Client-initiated channels MUST use odd IDs, server-initiated channels MUST use even IDs. +``` + +Requirements without a `UID:` field are skipped silently; tracey can't reference a requirement it can't name. + +**Source-side prefix.** `.sdoc` has no `PREFIX[…]` marker, so tracey uses the synthetic prefix `r` for these requirements. References from your code use the same `r[…]` syntax that markdown specs use, or — if you'd rather match StrictDoc's own conventions — the [`@relation(...)`](annotating-code.md#strictdoc-style-markers-relation) form. + +**UID case is preserved.** A spec declaring `UID: CH-001` is queried as `tracey query rule CH-001` — uppercase end-to-end. `CH-001` and `ch-001` are distinct rule IDs. + +**STATEMENT rendering.** When the document declares `OPTIONS: MARKUP: Markdown`, tracey renders STATEMENT fields through its markdown pipeline. Other `MARKUP:` values (`Text`, `RST`, or absent) are HTML-escaped and wrapped in `

`; an RST renderer is not bundled. + +Refer to the StrictDoc documentation for the full grammar; tracey reads the common subset (`[DOCUMENT]`, `[[SECTION]]`, `[REQUIREMENT]`, single-line and heredoc field values, and the document-level `OPTIONS:` block).