Repository navigation
feat(prove): prove properties of a script for every initial balance #219
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
Draft
ascandone
wants to merge
2
commits into
main
Choose a base branch
from
feat/prove
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,7 @@ | |
| goperf | ||
| gotools | ||
| just | ||
| z3 | ||
| ]; | ||
| unstablePackages = with pkgs-unstable; [ | ||
| golangci-lint | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/formancehq/numscript/internal/compiler" | ||
| "github.com/formancehq/numscript/internal/ir" | ||
| "github.com/formancehq/numscript/internal/parser" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| type IRArgs struct { | ||
| OutputPath string | ||
| } | ||
|
|
||
| // compileScriptToIR reads a numscript source file and compiles it to IR, | ||
| // printing parse and compile errors on stderr the way `run` does. | ||
| func compileScriptToIR(scriptPath string) ([]ir.Instr, error) { | ||
| content, err := readIRSource(scriptPath) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| src := string(content) | ||
|
|
||
| parseResult := parser.Parse(src) | ||
| if len(parseResult.Errors) != 0 { | ||
| fmt.Fprint(os.Stderr, parser.ParseErrorsToString(parseResult.Errors, src)) | ||
| return nil, fmt.Errorf("parsing failed") | ||
| } | ||
|
|
||
| instrs, _, err := compiler.CompileToIR(parseResult.Value, nil) | ||
| if err != nil { | ||
| fmt.Fprintln(os.Stderr, err.Error()) | ||
| if ranged, ok := err.(parser.Ranged); ok { | ||
| fmt.Fprintln(os.Stderr, ranged.GetRange().ShowOnSource(src)) | ||
| } | ||
| return nil, fmt.Errorf("compilation failed") | ||
| } | ||
| return instrs, nil | ||
| } | ||
|
|
||
| func dumpIR(scriptPath string, opts IRArgs) error { | ||
| instrs, err := compileScriptToIR(scriptPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| text := ir.Dump(instrs) | ||
| if opts.OutputPath == "" || opts.OutputPath == stdioPath { | ||
| _, err := os.Stdout.WriteString(text) | ||
| return err | ||
| } | ||
| return os.WriteFile(opts.OutputPath, []byte(text), 0o644) | ||
| } | ||
|
|
||
| func getIRCmd() *cobra.Command { | ||
| opts := IRArgs{} | ||
|
|
||
| cmd := cobra.Command{ | ||
| Use: "ir <file.num>", | ||
| Short: "Compile a numscript file and dump its textual IR", | ||
| Long: `Compile a numscript file and dump the textual IR the compiler produces, before | ||
| it is assembled into bytecode. The output goes to stdout unless --output says | ||
| otherwise, so it can be piped into assemble: | ||
| numscript ir folder/my-script.num | numscript assemble - > folder/my-script.numb | ||
|
|
||
| Pass - as the path to read the script from stdin. | ||
|
|
||
| The IR format tracks an unstable instruction set and is not a public interface. | ||
| `, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| err := dumpIR(args[0], opts) | ||
| if err != nil { | ||
| cmd.SilenceErrors = true | ||
| cmd.SilenceUsage = true | ||
| return err | ||
| } | ||
|
|
||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVarP(&opts.OutputPath, "output", "o", "", "Path where to write the IR ('-' for stdout)") | ||
|
|
||
| return &cmd | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/formancehq/numscript/internal/ir" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestDumpIRRoundTrips(t *testing.T) { | ||
| dir := t.TempDir() | ||
| scriptPath := filepath.Join(dir, "x.num") | ||
| require.NoError(t, os.WriteFile(scriptPath, []byte(`send [USD/2 10] ( | ||
| source = @src | ||
| destination = @dest | ||
| )`), 0o644)) | ||
|
|
||
| outPath := filepath.Join(dir, "x.ir") | ||
| require.NoError(t, dumpIR(scriptPath, IRArgs{OutputPath: outPath})) | ||
|
|
||
| text, err := os.ReadFile(outPath) | ||
| require.NoError(t, err) | ||
|
|
||
| instrs, errs := ir.Parse(string(text)) | ||
| require.Empty(t, errs) | ||
| require.Equal(t, string(text), ir.Dump(instrs)) | ||
| require.NoError(t, ir.Typecheck(instrs)) | ||
| } | ||
|
|
||
| func TestDumpIRReportsCompileErrors(t *testing.T) { | ||
| dir := t.TempDir() | ||
| scriptPath := filepath.Join(dir, "x.num") | ||
| require.NoError(t, os.WriteFile(scriptPath, []byte(`send [USD/2 10] ( | ||
| source = @src | ||
| destination = $dest | ||
| )`), 0o644)) | ||
|
|
||
| err := dumpIR(scriptPath, IRArgs{OutputPath: filepath.Join(dir, "x.ir")}) | ||
| require.EqualError(t, err, "compilation failed") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/formancehq/numscript/internal/ir" | ||
| "github.com/formancehq/numscript/internal/prove" | ||
| "github.com/formancehq/numscript/internal/prove/encode" | ||
| "github.com/formancehq/numscript/internal/prove/prop" | ||
| "github.com/formancehq/numscript/internal/prove/smt" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| type ProveArgs struct { | ||
| Props []string | ||
| PropsPath string | ||
| Solver string | ||
| Timeout time.Duration | ||
| EmitSMT string | ||
| OutFormatOpt string | ||
| } | ||
|
|
||
| // exitCodeError carries the exit code the prove command ends with. | ||
| type exitCodeError struct { | ||
| code int | ||
| } | ||
|
|
||
| func (e exitCodeError) Error() string { return fmt.Sprintf("exit %d", e.code) } | ||
|
|
||
| func loadInstrs(scriptPath string) ([]ir.Instr, error) { | ||
| if !strings.HasSuffix(scriptPath, ".ir") { | ||
| return compileScriptToIR(scriptPath) | ||
| } | ||
| content, err := readIRSource(scriptPath) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| src := string(content) | ||
| instrs, irErrs := ir.Parse(src) | ||
| if len(irErrs) != 0 { | ||
| for _, irErr := range irErrs { | ||
| fmt.Fprintln(os.Stderr, irErr.Error()) | ||
| fmt.Fprintln(os.Stderr, irErr.Range.ShowOnSource(src)) | ||
| } | ||
| return nil, fmt.Errorf("parsing failed") | ||
| } | ||
| return instrs, nil | ||
| } | ||
|
|
||
| func loadProps(scriptPath string, opts ProveArgs) ([]prop.Property, error) { | ||
| var props []prop.Property | ||
| for i, src := range opts.Props { | ||
| p, errs := prop.ParseInline(src) | ||
| if len(errs) != 0 { | ||
| for _, e := range errs { | ||
| fmt.Fprintf(os.Stderr, "--prop[%d]: %s\n", i+1, e.Error()) | ||
| fmt.Fprintln(os.Stderr, e.Range.ShowOnSource(src)) | ||
| } | ||
| return nil, fmt.Errorf("invalid property") | ||
| } | ||
| if p.Name == "" { | ||
| p.Name = fmt.Sprintf("prop%d", i+1) | ||
| } | ||
| props = append(props, p) | ||
| } | ||
|
|
||
| path := opts.PropsPath | ||
| if path == "" && len(opts.Props) == 0 { | ||
| path = scriptPath + ".props" | ||
| if _, err := os.Stat(path); err != nil { | ||
| return nil, fmt.Errorf("no properties: pass --prop, or create '%s'", path) | ||
| } | ||
| } | ||
| if path != "" { | ||
| content, err := os.ReadFile(path) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| src := string(content) | ||
| parsed, errs := prop.ParseFile(src) | ||
| if len(errs) != 0 { | ||
| for _, e := range errs { | ||
| fmt.Fprintf(os.Stderr, "%s:%s\n", path, e.Error()) | ||
| fmt.Fprintln(os.Stderr, e.Range.ShowOnSource(src)) | ||
| } | ||
| return nil, fmt.Errorf("invalid properties file") | ||
| } | ||
| props = append(props, parsed...) | ||
| } | ||
| return props, nil | ||
| } | ||
|
|
||
| func proveScript(scriptPath string, opts ProveArgs) error { | ||
| instrs, err := loadInstrs(scriptPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| props, err := loadProps(scriptPath, opts) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| solver := smt.Solver{Path: opts.Solver, Timeout: opts.Timeout} | ||
| if err := solver.Available(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| report, err := prove.Check(context.Background(), instrs, props, prove.Options{Solver: solver}) | ||
| if err != nil { | ||
| var unsupported encode.UnsupportedError | ||
| var propErr prove.PropertyError | ||
| switch { | ||
| case errors.As(err, &unsupported): | ||
| return fmt.Errorf("cannot prove: %w", err) | ||
| case errors.As(err, &propErr): | ||
| for _, e := range propErr.Errors { | ||
| fmt.Fprintf(os.Stderr, "property %q: %s\n", propErr.Property.Name, e.Error()) | ||
| } | ||
| return fmt.Errorf("invalid property") | ||
| } | ||
| return err | ||
| } | ||
|
|
||
| if opts.EmitSMT != "" { | ||
| for _, res := range report.Results { | ||
| if opts.EmitSMT == stdioPath { | ||
| fmt.Printf("; ---- %s\n%s\n", res.Property.Name, res.SMT) | ||
| continue | ||
| } | ||
| if err := os.MkdirAll(opts.EmitSMT, 0o755); err != nil { | ||
| return err | ||
| } | ||
| if err := os.WriteFile(filepath.Join(opts.EmitSMT, res.Property.Name+".smt2"), []byte(res.SMT), 0o644); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
|
|
||
| switch opts.OutFormatOpt { | ||
| case OutputFormatJson: | ||
| out, err := report.JSON(scriptPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := os.Stdout.Write(out); err != nil { | ||
| return err | ||
| } | ||
| case OutputFormatPretty: | ||
| if _, err := os.Stdout.WriteString(report.Pretty(scriptPath)); err != nil { | ||
| return err | ||
| } | ||
| default: | ||
| return fmt.Errorf("invalid output format: %s", opts.OutFormatOpt) | ||
| } | ||
|
|
||
| if code := report.ExitCode(); code != 0 { | ||
| return exitCodeError{code} | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func getProveCmd() *cobra.Command { | ||
| opts := ProveArgs{} | ||
|
|
||
| cmd := cobra.Command{ | ||
| Use: "prove <file.num|file.ir>", | ||
| Short: "Prove properties of a script for every initial balance", | ||
| Long: `Compile a numscript file (or parse a textual IR file) and check properties | ||
| written in the property language against it, for every initial balance. Each | ||
| property is either proved, refuted with a counterexample (initial balances the | ||
| real vm confirms), or left unknown when the solver times out. | ||
|
|
||
| Properties come from --prop (repeatable) and/or --props <file>; with neither, | ||
| '<script>.props' next to the script is used. See prove.md for the language. | ||
|
|
||
| Exit codes: 0 every property holds; 1 a property has a counterexample; 2 no | ||
| counterexample but a property is unknown; 3 the script is not supported, a | ||
| property is invalid, the solver is missing, or the prover disagrees with the vm. | ||
|
|
||
| Needs an SMT-LIB2 solver on PATH: z3 (default) or cvc5 (--solver cvc5). | ||
| `, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| err := proveScript(args[0], opts) | ||
| if err != nil { | ||
| cmd.SilenceUsage = true | ||
| var exit exitCodeError | ||
| if errors.As(err, &exit) { | ||
| os.Exit(exit.code) | ||
| } | ||
| cmd.SilenceErrors = true | ||
| fmt.Fprintln(os.Stderr, err) | ||
| os.Exit(3) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringArrayVar(&opts.Props, "prop", nil, "A property to check ('[name:] formula'); repeatable") | ||
| cmd.Flags().StringVar(&opts.PropsPath, "props", "", "Path of a properties file (default: '<script>.props')") | ||
| cmd.Flags().StringVar(&opts.Solver, "solver", "z3", "SMT-LIB2 solver executable: z3, cvc5, or a path") | ||
| cmd.Flags().DurationVar(&opts.Timeout, "timeout", 10*time.Second, "Solver time limit per property") | ||
| cmd.Flags().StringVar(&opts.EmitSMT, "emit-smt", "", "Write each property's SMT-LIB2 problem to this directory ('-' for stdout)") | ||
| cmd.Flags().StringVarP(&opts.OutFormatOpt, "output-format", "o", OutputFormatPretty, "Set the output format. Available options: pretty, json.") | ||
|
|
||
| return &cmd | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 [minor] Run the solver gate through the shared race-enabled tooling
The new Prove job installs an unpinned distro z3 and invokes plain
go test, while the Nix shell now supplies z3 and Justfile's tests recipe enables-race. Consequently solver-backed paths exercised by this gate are not race-checked, and this gate uses a different solver environment from local/shared CI testing. This contradicts RFC-0015's shared Nix/Just and race-enabled testing requirements.Suggestion: Run the required-solver gate using
nix develop --impure --command justwith a shared recipe that sets NUMSCRIPT_REQUIRE_Z3=1 and retains race checking (or use the existing tests recipe with that environment variable). Remove the separate apt/setup-go test environment.