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
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]`:
Expand Down
1 change: 1 addition & 0 deletions crates/tracey-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
110 changes: 91 additions & 19 deletions crates/tracey-core/src/code_units.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1666,6 +1666,16 @@ fn extract_full_refs_from_text(
warnings: &mut Vec<FullReqRefWarning>,
) {
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<char> = None;

Expand Down Expand Up @@ -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<FullReqRef>,
warnings: &mut Vec<FullReqRefWarning>,
) {
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,
Expand All @@ -1756,23 +1834,23 @@ enum ParsedFullRef {
fn try_parse_full_ref(
chars: &mut std::iter::Peekable<impl Iterator<Item = (usize, char)>>,
) -> Option<ParsedFullRef> {
// 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;
}

let mut first_word = String::new();
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 {
Expand All @@ -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 {
Expand All @@ -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 == '+'
Expand Down Expand Up @@ -1851,9 +1928,9 @@ fn try_parse_full_ref(
fn try_parse_req_ref(
chars: &mut std::iter::Peekable<impl Iterator<Item = (usize, char)>>,
) -> Option<RuleId> {
// 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;
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading