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
14 changes: 14 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1957,4 +1957,18 @@ artifacts:
status: implemented
tags: [classifier-match, connections, v093]

- id: REQ-ASSERTION-COMPARISON
type: requirement
title: Assertion DSL supports count() comparisons against integer literals
description: >
The assertion DSL shall evaluate pipeline expressions ending in
`.count()` followed by a comparison operator and integer literal
(`>=`, `>`, `<=`, `<`, `==`, `!=`) and return a Bool result. This
applies uniformly to Count, Components, Features, and Diagnostics
collection-valued pipelines. Resolves v0.9.x bug where
`components.where(...).count() >= n` failed to parse, leaving users
unable to express cardinality constraints in [[assertion]] blocks.
status: implemented
tags: [assertions, eval, v093]

# Research findings tracked separately in research/findings.yaml
20 changes: 20 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2544,3 +2544,23 @@ artifacts:
links:
- type: satisfies
target: REQ-CLASSIFIER-MATCH-002

- id: TEST-ASSERTION-COMPARISON
type: feature
title: count() comparisons evaluate correctly against integer literals
description: >
Unit tests in crates/spar-cli/src/assertion/mod.rs verify that
pipelines ending in `.count() <op> n` return the expected Bool
for each comparison operator: `>=`, `>`, `<=`, `<`, `==`, `!=`
against fixtures with known component counts (eval_count_ge_true,
eval_count_ge_false, eval_count_gt, eval_count_le, eval_count_lt,
eval_count_eq, eval_count_neq).
fields:
method: automated-test
steps:
- run: cargo test -p spar --bin spar -- assertion::tests::eval_count
status: passing
tags: [assertions, eval, v093]
links:
- type: satisfies
target: REQ-ASSERTION-COMPARISON
74 changes: 74 additions & 0 deletions crates/spar-cli/src/assertion/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub(crate) fn eval_node(node: &SyntaxNode, ctx: &EvalContext) -> Result<Value, E
}))
}
ExprSyntaxKind::PIPELINE_EXPR => eval_pipeline(node, ctx),
ExprSyntaxKind::COUNT_COMPARE_EXPR => eval_count_compare(node, ctx),
_ => Err(EvalError {
message: format!("unexpected top-level node: {:?}", node.kind()),
}),
Expand Down Expand Up @@ -89,6 +90,79 @@ fn eval_pipeline(node: &SyntaxNode, ctx: &EvalContext) -> Result<Value, EvalErro
Ok(value)
}

/// Evaluate a COUNT_COMPARE_EXPR: `pipeline <op> <integer>`.
fn eval_count_compare(node: &SyntaxNode, ctx: &EvalContext) -> Result<Value, EvalError> {
// Children: PIPELINE_EXPR, then tokens for the operator and integer.
// The PIPELINE_EXPR is the only child node; the operator and integer are tokens.
let pipeline_node = node
.children()
.find(|c| c.kind() == ExprSyntaxKind::PIPELINE_EXPR)
.ok_or_else(|| EvalError {
message: "count comparison missing pipeline".to_string(),
})?;

let pipeline_value = eval_pipeline(&pipeline_node, ctx)?;
let count = match pipeline_value {
Value::Count(n) => n,
Value::Components(ref comps) => comps.len(),
Value::Features(ref feats) => feats.len(),
Value::Diagnostics(ref diags) => diags.len(),
_ => {
return Err(EvalError {
message:
"count comparison requires a countable value (use .count() or a collection)"
.to_string(),
});
}
};

// Extract the comparison operator token and integer literal token.
let mut op_kind: Option<ExprSyntaxKind> = None;
let mut rhs_text: Option<String> = None;

for elem in node.children_with_tokens() {
if let rowan::NodeOrToken::Token(t) = elem {
match t.kind() {
ExprSyntaxKind::GE
| ExprSyntaxKind::GT
| ExprSyntaxKind::LE
| ExprSyntaxKind::LT
| ExprSyntaxKind::EQ_EQ
| ExprSyntaxKind::NEQ => {
op_kind = Some(t.kind());
}
ExprSyntaxKind::INT_LIT => {
rhs_text = Some(t.text().to_string());
}
ExprSyntaxKind::WHITESPACE => {}
_ => {}
}
}
}

let op = op_kind.ok_or_else(|| EvalError {
message: "count comparison missing operator".to_string(),
})?;
let rhs_str = rhs_text.ok_or_else(|| EvalError {
message: "count comparison missing integer operand".to_string(),
})?;
let rhs: usize = rhs_str.parse().map_err(|_| EvalError {
message: format!("invalid integer in count comparison: '{}'", rhs_str),
})?;

let result = match op {
ExprSyntaxKind::GE => count >= rhs,
ExprSyntaxKind::GT => count > rhs,
ExprSyntaxKind::LE => count <= rhs,
ExprSyntaxKind::LT => count < rhs,
ExprSyntaxKind::EQ_EQ => count == rhs,
ExprSyntaxKind::NEQ => count != rhs,
_ => unreachable!(),
};

Ok(Value::Bool(result))
}

/// Evaluate a source expression: `components` or `analysis('name')`.
fn eval_source(node: &SyntaxNode, ctx: &EvalContext) -> Result<Value, EvalError> {
match node.kind() {
Expand Down
28 changes: 28 additions & 0 deletions crates/spar-cli/src/assertion/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ pub(crate) fn lex(input: &str) -> Vec<(ExprSyntaxKind, String)> {
pos += 2;
tokens.push((ExprSyntaxKind::EQ_EQ, "==".to_string()));
}
b'!' if pos + 1 < bytes.len() && bytes[pos + 1] == b'=' => {
pos += 2;
tokens.push((ExprSyntaxKind::NEQ, "!=".to_string()));
}
b'>' if pos + 1 < bytes.len() && bytes[pos + 1] == b'=' => {
pos += 2;
tokens.push((ExprSyntaxKind::GE, ">=".to_string()));
}
b'>' => {
pos += 1;
tokens.push((ExprSyntaxKind::GT, ">".to_string()));
}
b'<' if pos + 1 < bytes.len() && bytes[pos + 1] == b'=' => {
pos += 2;
tokens.push((ExprSyntaxKind::LE, "<=".to_string()));
}
b'<' => {
pos += 1;
tokens.push((ExprSyntaxKind::LT, "<".to_string()));
}

// Integer literals
b if b.is_ascii_digit() => {
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
tokens.push((ExprSyntaxKind::INT_LIT, input[start..pos].to_string()));
}

// Identifiers and keywords
b if is_ident_start(b) => {
Expand Down
53 changes: 53 additions & 0 deletions crates/spar-cli/src/assertion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,4 +1184,57 @@ check = "components.count()"
assert!(file.assertion[0].description.is_empty());
assert_eq!(file.assertion[0].severity, SeverityFilter::Error);
}

fn assert_bool(expr: &str, expected: bool) {
let inst = make_test_instance();
let diags = vec![];
let ctx = EvalContext {
instance: &inst,
diagnostics: &diags,
};
match eval_check(expr, &ctx).unwrap() {
Value::Bool(b) => assert_eq!(b, expected, "expr: {expr}"),
other => panic!("expr {expr}: expected Bool, got {:?}", other),
}
}

#[test]
fn eval_count_ge_true() {
assert_bool("components.where(category == 'thread').count() >= 2", true);
}

#[test]
fn eval_count_ge_false() {
assert_bool("components.where(category == 'thread').count() >= 3", false);
}

#[test]
fn eval_count_gt() {
assert_bool("components.where(category == 'thread').count() > 1", true);
assert_bool("components.where(category == 'thread').count() > 2", false);
}

#[test]
fn eval_count_le() {
assert_bool("components.where(category == 'thread').count() <= 2", true);
assert_bool("components.where(category == 'thread').count() <= 1", false);
}

#[test]
fn eval_count_lt() {
assert_bool("components.where(category == 'thread').count() < 3", true);
assert_bool("components.where(category == 'thread').count() < 2", false);
}

#[test]
fn eval_count_eq() {
assert_bool("components.where(category == 'thread').count() == 2", true);
assert_bool("components.where(category == 'thread').count() == 3", false);
}

#[test]
fn eval_count_neq() {
assert_bool("components.where(category == 'thread').count() != 3", true);
assert_bool("components.where(category == 'thread').count() != 2", false);
}
}
50 changes: 49 additions & 1 deletion crates/spar-cli/src/assertion/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ impl ExprParser {

fn parse_root(&mut self) {
self.builder.start_node(ExprSyntaxKind::ROOT.into());
self.parse_pipeline();
self.parse_pipeline_or_count_compare();
self.eat_ws();
if !self.at_eof() {
let text = self.current_text().to_string();
Expand All @@ -198,6 +198,54 @@ impl ExprParser {
self.builder.finish_node();
}

/// Parse a pipeline optionally followed by a count comparison operator and integer.
fn parse_pipeline_or_count_compare(&mut self) {
// Peek after the pipeline to see if there's a comparison operator.
// We do this by checking the token stream ahead. If the pipeline is followed
// by a comparison op (>=, >, <=, <, ==, !=) and then an INT_LIT, we wrap
// everything in COUNT_COMPARE_EXPR.
if self.has_count_compare_ahead() {
self.builder
.start_node(ExprSyntaxKind::COUNT_COMPARE_EXPR.into());
self.parse_pipeline();
self.bump(); // comparison operator
self.bump(); // integer literal
self.builder.finish_node();
} else {
self.parse_pipeline();
}
}

/// Returns true if, looking ahead, there is a comparison operator followed
/// by an INT_LIT at the end of the token stream (after the pipeline).
/// We scan non-whitespace tokens to find the pattern: ... <comp_op> <INT_LIT> EOF.
fn has_count_compare_ahead(&self) -> bool {
// Collect non-whitespace token kinds
let kinds: Vec<ExprSyntaxKind> = self
.tokens
.iter()
.skip(self.pos)
.filter(|(k, _)| *k != ExprSyntaxKind::WHITESPACE)
.map(|(k, _)| *k)
.collect();
// Pattern: last two tokens must be <comp_op> <INT_LIT>
if kinds.len() < 2 {
return false;
}
let last = kinds[kinds.len() - 1];
let second_last = kinds[kinds.len() - 2];
last == ExprSyntaxKind::INT_LIT
&& matches!(
second_last,
ExprSyntaxKind::GE
| ExprSyntaxKind::GT
| ExprSyntaxKind::LE
| ExprSyntaxKind::LT
| ExprSyntaxKind::EQ_EQ
| ExprSyntaxKind::NEQ
)
}

/// pipeline = source ( '.' method )*
fn parse_pipeline(&mut self) {
self.builder
Expand Down
14 changes: 14 additions & 0 deletions crates/spar-cli/src/assertion/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ pub(crate) enum ExprSyntaxKind {
R_PAREN,
/// `==`
EQ_EQ,
/// `!=`
NEQ,
/// `>=`
GE,
/// `>`
GT,
/// `<=`
LE,
/// `<`
LT,
/// An integer literal: `0`, `1`, `42`.
INT_LIT,
/// `,`
COMMA,

Expand Down Expand Up @@ -59,6 +71,8 @@ pub(crate) enum ExprSyntaxKind {
IDENT_EXPR,
/// `message.contains('text')` expression.
CONTAINS_EXPR,
/// Count comparison: `pipeline >= n`, `pipeline == n`, etc.
COUNT_COMPARE_EXPR,

/// Sentinel: must be last.
#[doc(hidden)]
Expand Down
Loading