Skip to content

Fix/bad syntax binding error variants #6343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from

Conversation

jcnelson
Copy link
Member

Description

This PR improves the expressiveness of CheckErrors::BadSyntaxBinding and some related binding errors to indicate both the kind of binding considered, as well as the specific structural deficiency. Specifically, it differentiates between faulty let-bindings, function definitions, and tuple constructors, and it treats all instance of invalid type signatures found in top-level definitions as CheckErrors::BadSyntaxBinding while reporting the inner CheckErrors error message and innermost faulty binding in the signature.

For example, in develop today, these errors are reported.

$ echo '(let ((u1 u2)) true)' | clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
BadSyntaxBinding
$ echo '(define-public (foo (string -19)) (ok true))' | clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
BadSyntaxBinding
$ echo '(tuple oops (foo u1))' | clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
TupleExpectsPairs

In this PR, these same errors are as follows:

$ echo '(let ((u1 u2)) true)' | /tmp/clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
BadSyntaxBinding(NotAtom(Let, 0, SymbolicExpression { expr: LiteralValue(UInt(1)), id: 5 }))
$ echo '(define-public (foo (string -19)) (ok true))' | /tmp/clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
BadSyntaxBinding(BadTypeSignature(0, SymbolicExpression { expr: LiteralValue(Int(-19)), id: 7 }, "supplied type description is invalid"))
$ echo '(tuple oops (foo u1))' | /tmp/clarity-cli eval_raw - | jq -r '.error.analysis' | head -n 1
BadSyntaxBinding(NotList(TupleCons, 0, SymbolicExpression { expr: Atom(ClarityName("oops")), id: 3 }))

Right now, this PR does not touch CheckErrors::BadMayTypeDefinition, but I'm happy to add that as well.

Applicable issues

Additional info (benefits, drawbacks, caveats)

Looking at where the errors were used, it does not appear that any changes are consensus critical. I could not find any place in the codebase where the caller matches on a CheckErrors value affected by this PR.

Checklist

  • Test coverage for new or modified code paths
  • Changelog is updated
  • Required documentation changes (e.g., docs/rpc/openapi.yaml and rpc-endpoints.md for v2 endpoints, event-dispatcher.md for new events)
  • New clarity functions have corresponding PR in clarity-benchmarking repo
  • New integration test(s) added to bitcoin-tests.yml

jcnelson added 20 commits July 31, 2025 16:46
… formatting code) to specialize CheckErrors::BadSyntaxBinding. Also, remove tuple-specific bad syntax binding errors since they are now captured by BadSyntaxBinding
…e-bindings, report the specific kind of error instead of the generic BadSyntaxBinding
…kind of binding being checked so an error will result in a meaningful message
…ype-check being performed so error messages can reflect it
… to now check for the specific variant of BadSyntaxBinding. Also, add comprehensive checks for each variant of BadSyntaxBinding, as well as test to verify that error messages do not grow unbound with the type's nesting depth
…nding error and the specific context in which it occurs
…reporting, and consolidate Display implementation for SymbolicExpression and SymbolicExpressionType
…and update tests to expect when BadSyntaxBinding wraps another CheckErrors variant
…g expecting it to wrap an inner CheckErrors variant which lead to a bad binding
…xBindingError::BadTypeSignature(..) ..), instead of BadSyntaxExpectedListOfPairs, so the caller has a better idea about what is structurally deficient about the binding or type signature
@jcnelson jcnelson requested review from a team as code owners July 31, 2025 21:38
@jcnelson jcnelson changed the base branch from master to develop July 31, 2025 21:38
@jcnelson jcnelson requested review from kantai and obycode August 1, 2025 15:32
Copy link

codecov bot commented Aug 1, 2025

Codecov Report

❌ Patch coverage is 86.99634% with 71 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.73%. Comparing base (799953e) to head (1396f49).
⚠️ Report is 11 commits behind head on develop.

Files with missing lines Patch % Lines
...ty/src/vm/analysis/type_checker/v2_05/tests/mod.rs 43.47% 26 Missing ⚠️
...ity/src/vm/analysis/type_checker/v2_1/tests/mod.rs 84.88% 26 Missing ⚠️
clarity/src/vm/analysis/errors.rs 86.11% 10 Missing ⚠️
...y/src/vm/analysis/type_checker/v2_1/natives/mod.rs 87.50% 7 Missing ⚠️
.../src/vm/analysis/type_checker/v2_05/natives/mod.rs 94.73% 2 Missing ⚠️

❌ Your project check has failed because the head coverage (72.73%) is below the target coverage (80.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #6343      +/-   ##
===========================================
- Coverage    81.00%   72.73%   -8.28%     
===========================================
  Files          541      541              
  Lines       347855   348261     +406     
===========================================
- Hits        281772   253293   -28479     
- Misses       66083    94968   +28885     
Files with missing lines Coverage Δ
clarity/src/vm/analysis/read_only_checker/mod.rs 87.81% <100.00%> (+1.17%) ⬆️
clarity/src/vm/analysis/tests/mod.rs 76.67% <100.00%> (-23.33%) ⬇️
clarity/src/vm/analysis/type_checker/v2_05/mod.rs 89.84% <100.00%> (-0.52%) ⬇️
clarity/src/vm/analysis/type_checker/v2_1/mod.rs 90.05% <100.00%> (-0.35%) ⬇️
clarity/src/vm/errors.rs 60.52% <ø> (-13.16%) ⬇️
clarity/src/vm/functions/define.rs 98.46% <100.00%> (-0.38%) ⬇️
clarity/src/vm/functions/mod.rs 98.03% <100.00%> (+0.06%) ⬆️
clarity/src/vm/functions/tuples.rs 83.33% <100.00%> (ø)
clarity/src/vm/representations.rs 84.24% <100.00%> (+2.76%) ⬆️
clarity/src/vm/tests/datamaps.rs 82.54% <100.00%> (-16.54%) ⬇️
... and 8 more

... and 285 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 799953e...1396f49. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@obycode obycode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. I just noted a couple of import clippy warnings that need to be fixed.

@obycode obycode added this to the 3.2.0.0.1 milestone Aug 15, 2025
@obycode obycode moved this to Status: In Review in Stacks Core Eng Aug 15, 2025
@obycode obycode modified the milestones: 3.2.0.0.1, 3.2.0.0.2 Aug 15, 2025
Comment on lines 115 to 158
/// Helper constructor for NotList(SyntaxBindingErrorType::Let, item_no, item)
pub fn let_binding_not_list(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotList(SyntaxBindingErrorType::Let, item_no, item)
}

/// Helper constructor for InvalidLength(SyntaxBindingErrorType::Let, item_no, item)
pub fn let_binding_invalid_length(item_no: usize, item: SymbolicExpression) -> Self {
Self::InvalidLength(SyntaxBindingErrorType::Let, item_no, item)
}

/// Helper constructor for NotAtom(SyntaxBindingErrorType::Let, item_no, item)
pub fn let_binding_not_atom(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotAtom(SyntaxBindingErrorType::Let, item_no, item)
}

/// Helper constructor for NotList(SyntaxBindingErrorType::Eval, item_no, item)
pub fn eval_binding_not_list(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotList(SyntaxBindingErrorType::Eval, item_no, item)
}

/// Helper constructor for InvalidLength(SyntaxBindingErrorType::Eval, item_no, item)
pub fn eval_binding_invalid_length(item_no: usize, item: SymbolicExpression) -> Self {
Self::InvalidLength(SyntaxBindingErrorType::Eval, item_no, item)
}

/// Helper constructor for NotAtom(SyntaxBindingErrorType::Eval, item_no, item)
pub fn eval_binding_not_atom(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotAtom(SyntaxBindingErrorType::Eval, item_no, item)
}

/// Helper constructor for NotList(SyntaxBindingErrorType::TupleCons, item_no, item)
pub fn tuple_cons_not_list(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotList(SyntaxBindingErrorType::TupleCons, item_no, item)
}

/// Helper constructor for InvalidLength(SyntaxBindingErrorType::TupleCons, item_no, item)
pub fn tuple_cons_invalid_length(item_no: usize, item: SymbolicExpression) -> Self {
Self::InvalidLength(SyntaxBindingErrorType::TupleCons, item_no, item)
}

/// Helper constructor for NotAtom(SyntaxBindingErrorType::TupleCons, item_no, item)
pub fn tuple_cons_not_atom(item_no: usize, item: SymbolicExpression) -> Self {
Self::NotAtom(SyntaxBindingErrorType::TupleCons, item_no, item)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these helper constructors could return a CheckErrors::BadSyntaxBinding(..) which would simplify their invocations (I think every invocation of one of these functions wraps the result in a CheckErrors::BadSyntaxBinding). Alternatively, you could define a From<SyntaxBindingError> for CheckErrors and achieve similar cleanups.

Comment on lines 60 to 69
NotList(SyntaxBindingErrorType, usize, SymbolicExpression),
/// binding list item has an invalid length (e.g. not 2)
InvalidLength(SyntaxBindingErrorType, usize, SymbolicExpression),
/// binding name is not an atom
NotAtom(SyntaxBindingErrorType, usize, SymbolicExpression),
/// second binding item is a type signature, and the type signature itself is bad.
/// NOTE: type signature parsing returns CheckErrors, so we cannot include a CheckErrors here
/// directly without creating a recursive type. Instead, we just report the `Display`
/// representation of the error here as the third item.
BadTypeSignature(usize, SymbolicExpression, String),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little wary of including the SymbolicExpression in this error. Not just because it'll end up duplicating a portion of the AST, but CheckError also will contain the SymbolicExpression (I think as it is now, that ends up being the outer function, whether its a let or tuple or whatever). Importantly for debugging, the CheckError's symbolic expression is the one that is used to set the diagnostic struct in that error (which sets the line spans for the error) -- this is what tells LSPs where the error is.

I think the better thing to do would be to drop the SymbolicExpression from this enum, and instead set the SymbolicExpression on the assembled CheckError. This could be kind of a pain to do, except that you already have helper functions, so you could change the return type of the helper constructors to CheckError, and make sure that they invoke the .set_expression():

impl CheckError {
     with_expression(err: CheckErrors, expr: &SymbolicExpression) -> CheckError {
         let mut r = Self::new(err);
         r.set_expression(expr);
         r
     }
}

...

impl SyntaxBindingError {
    /// Helper constructor for NotList(SyntaxBindingErrorType::Let, item_no, item)
    pub fn let_binding_not_list(item_no: usize, item: &SymbolicExpression) -> CheckError {
        let inner_err = Self::NotList(SyntaxBindingErrorType::Let, item_no);
        CheckError::with_expression(CheckErrors::BindingSyntaxError(inner_err))
    }

Note that the current set_expression function takes a reference arg and then clones internally, so to avoid excessive clones, we should either make sure that we pass a reference and never clone beforehand or we refactor set_expression to take an owned argument, so that the caller can take care of cloning-if-necessary.

return Err(CheckErrors::BadSyntaxBinding(
SyntaxBindingError::let_binding_invalid_length(
i,
SymbolicExpression::list(pair_expression.to_vec()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
SymbolicExpression::list(pair_expression.to_vec()),
pair.clone(),

return Err(CheckErrors::BadSyntaxBinding(
SyntaxBindingError::tuple_cons_invalid_length(
i,
SymbolicExpression::list(pair_expression.to_vec()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
SymbolicExpression::list(pair_expression.to_vec()),
pair.clone()

Comment on lines 670 to 672
where
F: FnMut(&ClarityName, &SymbolicExpression) -> std::result::Result<(), E>,
E: From<CheckErrors>,
Copy link
Contributor

@kantai kantai Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay -- this is where I think my prior suggestion becomes problematic. This function returns E: From<CheckErrors> error types, which would be a problem if we're trying to use the CheckError to handle the symbolic expression. But the usage of this makes me even more skeptical of including portions of the AST in the error struct -- handle_binding_list also generates runtime errors (runtime-error wrapped CheckErrors). I don't think runtime errors should be cloning the AST. But anyways, I think a maybe cleaner thing to do here would be to update this function's signature:

Suggested change
where
F: FnMut(&ClarityName, &SymbolicExpression) -> std::result::Result<(), E>,
E: From<CheckErrors>,
where
F: FnMut(&ClarityName, &SymbolicExpression) -> std::result::Result<(), E>,
E: From<(SyntaxBindingError, &SymbolicExpression)>,

That conveys (correctly) that the handler only returns BindingErrors, so E just needs to know how to convert from those, and then we'd define the From<(SyntaxBindingError, &SymbolicExpression)> impls explicitly -- that way this function no longer sometimes implicitly relies on the From<CheckErrors> implementation for runtime errors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we force the caller to supply a convert_fn:

pub fn handle_binding_list<F, E>(
    bindings: &[SymbolicExpression],
    binding_error_type: SyntaxBindingErrorType,
    on_err: G, 
    mut handler: F,
) -> std::result::Result<(), E>
where
    G: Fn(SyntaxBindingError, &SymbolicExpression) -> E,
    F: FnMut(&ClarityName, &SymbolicExpression) -> std::result::Result<(), E> {

/// NOTE: type signature parsing returns CheckErrors, so we cannot include a CheckErrors here
/// directly without creating a recursive type. Instead, we just report the `Display`
/// representation of the error here as the third item.
BadTypeSignature(usize, SymbolicExpression, String),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not include a SyntaxBindingErrorType for BadTypeSignature?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at the case where BadTypeSignature arises (in parse_name_type_pairs), I'm actually not convinced that this should be a binding error at all. It seems like it should just be the type definition error (with the CheckError span/diagnostic data pointing at the expression that has the invalid definition). I think the only reason this wouldn't be the case is that it makes the function signature of parse_name_type_pairs simpler (i.e., it avoids needing to do more error conversion logic), but from a user/debugging perspective, it seems like this case really should just be whatever the underlying error was (and the debuggability over the existing implementation would be improved by making sure that the CheckError diagnostic/expression actually points to the offending signature).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VM currently treats a type-check error within a syntax binding as a CheckErrors::BadSyntaxBinding instead of the underlying type error. For example:

$ cat /tmp/bad-type-definition.clar 
(define-private (foo (bar (string-ascii -12))) (ok true))

$ clarity-cli check /tmp/bad-type-definition.clar | jq
{
  "error": {
    "analysis": {
      "level": "Error",
      "message": "invalid syntax binding",
      "spans": [
        {
          "end_column": 0,
          "end_line": 0,
          "start_column": 0,
          "start_line": 0
        }
      ],
      "suggestion": "binding syntax example: ((supply int) (ttl int))"
    }
  },
  "message": "Checks failed."
}

The BadTypeSignature variant captures this specific class of error and produces a much more useful message:

$ ./target/debug/clarity-cli check /tmp/bad-type-definition.clar | jq
{
  "error": {
    "analysis": {
      "level": "Error",
      "message": "invalid syntax binding: Type definition item #1 has an invalid type signature: ( string-ascii -12 ) (reason: created a type which value size was out of defined bounds)",
      "spans": [
        {
          "end_column": 0,
          "end_line": 0,
          "start_column": 0,
          "start_line": 0
        }
      ],
      "suggestion": null
    }
  },
  "message": "Checks failed."

Specifically, string S (reason: {S}) is the DiagnosableError::message() output for the inner error (in this case, a CheckErrors::ValueOutOfBounds).

This PR keeps the current behavior of treating type-check errors within syntax bindings as CheckErrors::BadSyntaxBinding.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at the case where BadTypeSignature arises (in parse_name_type_pairs), I'm actually not convinced that this should be a binding error at all.

I agree in principle, but I'd rather avoid risking a consensus-breaking change by changing the error variant.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error variant is not consensus critical here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To answer the original question:

Why not include a SyntaxBindingErrorType for BadTypeSignature?

This error variant is meant to capture the inner CheckErrors error which caused the BadSyntaxBinding, so the developer can deduce what's wrong with the syntax binding. Right now, this variant takes a String to capture the DiagnosableError::message() output for the inner error. It could alternatively take a Box<CheckErrors>, but I didn't do this because we're really only interested in reporting to the developer what the inner error was (i.e. there's no value in preserving the type). In addition, the code avoids producing a very long String (or alternatively, a series of nested Box<CheckErrors>) by dropping any inner intermediate BadSyntaxBinding errors, and only reporting the outermost error which was not a BadSyntaxBinding. This is handled by this code here, in parse_name_type_pairs():

            let type_info = TypeSignature::parse_type_repr(epoch, type_symbol, accounting)
                .map_err(|e| {
                    SyntaxBindingError::BadTypeSignature(
                        i,
                        (*type_symbol).clone(),
                        // if the inner error is itself a BadTypeSignature error, and it's
                        // `message` came from a BadSyntaxBinding, then just use its
                        // message directly so we don't get a tower of nested BadTypeSignature
                        // messages.  We only want one level of nesting, so something like
                        // `(string-ascii -19)` gets reported instead of `-19` (so the caller gets
                        // some context, but not an unreasonably large amount)
                        if let CheckErrors::BadSyntaxBinding(
                            SyntaxBindingError::BadTypeSignature(_, _, message),
                        ) = &e
                        {
                            if CheckErrors::has_nested_bad_syntax_binding_message(message) {
                                message.clone()
                            } else {
                                e.message()
                            }
                        } else {
                            e.message()
                        },
                    ).into()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it were consensus critical, this changeset already changes the error variant for this case -- parse_name_type_pairs is used by other callers than just the function argument binding (in particular, its used for parsing tuple type definitions). So for something like from-consensus-buff? your changeset would alter the error variant from the underlying one to a BadSyntaxBinding error.

Try this contract:

(from-consensus-buff? (tuple (a (string-ascii -12))) 0x00)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it were consensus critical, this changeset already changes the error variant for this case

That was unintentional on my part.

I'm happy to follow your recommendation and update parse_name_type_pairs() to return the inner CheckErrors as it is. I'm pretty sure it's not consensus-critical since BadSyntaxBinding is not used in any code path in stackslib or stacks-node (but other CheckErrors are).

Copy link
Contributor

@kantai kantai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have two high-level pieces of feedback on the new error type:

  1. The new error struct shouldn't keep a copy of the expression. Either the struct and the binding handlers should be refactored so that this can be avoided (making sure to pass the correct expression to the CheckError struct). Another option is that rather than wrap the SyntaxBindingError into a CheckErrors variant, we do something like make each of the SyntaxBindingError variants into a new CheckErrors variant, and then define From<SyntaxBindingError> for CheckError which pulls the expression out of the SyntaxBindingError (so rather than wrapping a SyntaxBindingError, we're converting from a SyntaxBindingError). I'm a little agnostic between those, but I definitely think that using the CheckError's diagnostic/expression field is the right thing to do.

  2. BadTypeSignature should be dropped, and the underlying error should just be returned. I think the pain of returning the underlying error is that previously the CheckError expression that is constructed used the whole binding as the expression (e.g., the tuple type definition) rather than the just the offending expression. So instead, we should just make sure that we're setting the offending expression on the returned CheckError. This will probably involve some careful type handling in the parse_name_type_pairs function, but it certainly seems doable.

@github-project-automation github-project-automation bot moved this from Status: In Review to Status: 💻 In Progress in Stacks Core Eng Aug 15, 2025
@jcnelson jcnelson requested review from kantai and obycode August 15, 2025 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Status: 💻 In Progress
Development

Successfully merging this pull request may close these issues.

[VM] Anemic VM errors in type-checking bindings
3 participants