Skip to content

Commit

Permalink
lib: add support for execution coverage reporting
Browse files Browse the repository at this point in the history
  • Loading branch information
efd6 committed Oct 21, 2024
1 parent b3efc2e commit ab2ae47
Show file tree
Hide file tree
Showing 5 changed files with 389 additions and 35 deletions.
244 changes: 244 additions & 0 deletions lib/coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package lib

import (
"bytes"
"errors"
"fmt"
"sort"
"strings"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/interpreter"
)

// NewCoverage return an execution coverage statistics collector for the
// provided AST.
func NewCoverage(ast *cel.Ast) *Coverage {
return &Coverage{
ast: ast,
decorator: coverage{
all: make(map[int64]bool),
cov: make(map[int64]bool),
},
}
}

// Coverage is a CEL program execution coverage statistics collector.
type Coverage struct {
ast *cel.Ast
decorator coverage
}

// ProgramOption return a cel.ProgramOption that can be used in a call to
// cel.Env.Program to collect coverage information from the program's execution.
func (c *Coverage) ProgramOption() cel.ProgramOption {
return cel.CustomDecorator(func(i interpreter.Interpretable) (interpreter.Interpretable, error) {
c.decorator.all[i.ID()] = true
switch i := i.(type) {
case interpreter.InterpretableAttribute:
return coverageAttribute{InterpretableAttribute: i, cov: c.decorator.cov}, nil
default:
return coverage{Interpretable: i, cov: c.decorator.cov}, nil
}
})
}

func (c *Coverage) String() string {
var buf bytes.Buffer
for i, d := range c.Details() {
if i != 0 {
fmt.Fprintln(&buf)
}
fmt.Fprintf(&buf, "%s", d)
}
return buf.String()
}

// Merge adds node coverage from o into c. If c was constructed with NewCoverage
// o and c must have been constructed with the AST from the same source. If o is
// nil, Merge is a no-op.
func (c *Coverage) Merge(o *Coverage) error {
if o == nil {
return nil
}
if c.decorator.all == nil {
*c = *o
return nil
}
if !equalNodes(c.decorator.all, o.decorator.all) {
return errors.New("cannot merge unrelated coverage: mismatched nodes")
}
if c.ast.Source().Content() != o.ast.Source().Content() {
return errors.New("cannot merge unrelated coverage: mismatched source")
}
for id := range o.decorator.cov {
c.decorator.cov[id] = true
}
return nil
}

func equalNodes(a, b map[int64]bool) bool {
if len(a) != len(b) {
return false
}
for k, v1 := range a {
if v2, ok := b[k]; !ok || v1 != v2 {
return false
}
}
return true
}

// Details returns the coverage details from running the target CEL program.
func (c *Coverage) Details() []LineCoverage {
nodes := make(map[int][]int64)
for id := range c.decorator.all {
line := c.ast.NativeRep().SourceInfo().GetStartLocation(id).Line()
nodes[line] = append(nodes[line], id)
}
hits := make(map[int][]int64)
for id := range c.decorator.cov {
line := c.ast.NativeRep().SourceInfo().GetStartLocation(id).Line()
hits[line] = append(hits[line], id)
}
stats := make(map[int]float64)
var lines []int
for l := range nodes {
sort.Slice(nodes[l], func(i, j int) bool { return nodes[l][i] < nodes[l][j] })
sort.Slice(hits[l], func(i, j int) bool { return hits[l][i] < hits[l][j] })
stats[l] = float64(len(hits[l])) / float64(len(nodes[l]))
lines = append(lines, l)
}
sort.Ints(lines)
cov := make([]LineCoverage, 0, len(lines))
src := c.ast.Source()
for _, l := range lines {
var missed []int64
i, j := 0, 0
for i < len(nodes[l]) && j < len(hits[l]) {
if nodes[l][i] == hits[l][j] {
i++
j++
continue
}
missed = append(missed, nodes[l][i])
i++
}
missed = append(missed, nodes[l][i:]...)
cov = append(cov, LineCoverage{
Line: l,
Coverage: stats[l],
Nodes: nodes[l],
Covered: hits[l],
Missed: missed,
Annotation: srcAnnot(c.ast, src, missed, "!"),
})
}
return cov
}

func srcAnnot(ast *cel.Ast, src common.Source, nodes []int64, mark string) string {
if len(nodes) == 0 {
return ""
}
var buf bytes.Buffer
columns := make(map[int]bool)
var snippet string
for _, id := range nodes {
loc := ast.NativeRep().SourceInfo().GetStopLocation(id)
if columns[loc.Column()] {
continue
}
columns[loc.Column()] = true
if snippet == "" {
var ok bool
snippet, ok = src.Snippet(loc.Line())
if !ok {
continue
}
}
}
missed := make([]int, 0, len(columns))
for col := range columns {
missed = append(missed, col)
}
sort.Ints(missed)
fmt.Fprintln(&buf, " | "+strings.Replace(snippet, "\t", " ", -1))
fmt.Fprint(&buf, " | ")
var last int
for _, col := range missed {
fmt.Fprint(&buf, strings.Repeat(" ", minInt(col, len(snippet))-last)+mark)
last = col + 1
}
return buf.String()
}

type coverage struct {
interpreter.Interpretable
all map[int64]bool
cov map[int64]bool
}

func (c coverage) Eval(a interpreter.Activation) ref.Val {
c.cov[c.ID()] = true
return c.Interpretable.Eval(a)
}

type coverageAttribute struct {
interpreter.InterpretableAttribute
all map[int64]bool
cov map[int64]bool
}

func (c coverageAttribute) Eval(a interpreter.Activation) ref.Val {
c.cov[c.ID()] = true
return c.InterpretableAttribute.Eval(a)
}

// LineCoverage is the execution coverage data for a single line of a CEL
// program.
type LineCoverage struct {
// Line is the line number of the program.
Line int `json:"line"`
// Coverage is the fraction of CEL expression nodes
// executed on the line.
Coverage float64 `json:"coverage"`
// Nodes is the full set of expression nodes on
// the line.
Nodes []int64 `json:"nodes"`
// Nodes is the set of expression nodes that were
// executed.
Covered []int64 `json:"covered"`
// Nodes is the set of expression nodes that were
// not executed.
Missed []int64 `json:"missed"`
// Annotation is a textual representation of the
// line, marking positions that were not executed.
Annotation string `json:"annotation"`
}

func (c LineCoverage) String() string {
if c.Annotation == "" {
return fmt.Sprintf("%d: %0.2f (%d/%d)", c.Line, c.Coverage, len(c.Covered), len(c.Nodes))
}
return fmt.Sprintf("%d: %0.2f (%d/%d) %v\n%s", c.Line, c.Coverage, len(c.Covered), len(c.Nodes), c.Missed, c.Annotation)
}
53 changes: 40 additions & 13 deletions mito.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func Main() int {
maxTraceBody := flag.Int("max_log_body", 1000, "maximum length of body logged in request traces (go1.21+)")
fold := flag.Bool("fold", false, "apply constant folding optimisation")
dumpState := flag.String("dump", "", "dump eval state ('always' or 'error')")
coverage := flag.String("coverage", "", "file to write an execution coverage report to (prefix if multiple executions are run)")
version := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *version {
Expand Down Expand Up @@ -195,8 +196,13 @@ func Main() int {
input = map[string]interface{}{root: input}
}

var cov lib.Coverage
for n := int(0); *maxExecutions < 0 || n < *maxExecutions; n++ {
res, val, dump, err := eval(string(b), root, input, *fold, *dumpState != "", libs...)
res, val, dump, c, err := eval(string(b), root, input, *fold, *dumpState != "", *coverage != "", libs...)
if err := cov.Merge(c); err != nil {
fmt.Fprintf(os.Stderr, "internal error merging coverage: %v\n", err)
return 2
}
if *dumpState == "always" {
fmt.Fprint(os.Stderr, dump)
}
Expand All @@ -220,6 +226,22 @@ func Main() int {
}
input = map[string]any{"state": val}
}
if *coverage != "" {
f, err := os.Create(*coverage)
if err != nil {
fmt.Fprintf(os.Stderr, "internal error opening coverage file: %v\n", err)
return 2
}
defer func() {
f.Sync()
f.Close()
}()
_, err = f.WriteString(cov.String() + "\n")
if err != nil {
fmt.Fprintf(os.Stderr, "internal error writing coverage file: %v\n", err)
return 2
}
}
return 0
}

Expand Down Expand Up @@ -332,53 +354,58 @@ func debug(tag string, value any) {
fmt.Fprintf(os.Stderr, "%s: logging %q: %v\n", level, tag, value)
}

func eval(src, root string, input interface{}, fold, details bool, libs ...cel.EnvOption) (string, any, *lib.Dump, error) {
prg, ast, err := compile(src, root, fold, details, libs...)
func eval(src, root string, input interface{}, fold, details, coverage bool, libs ...cel.EnvOption) (string, any, *lib.Dump, *lib.Coverage, error) {
prg, ast, cov, err := compile(src, root, fold, details, coverage, libs...)
if err != nil {
return "", nil, nil, fmt.Errorf("failed program instantiation: %v", err)
return "", nil, nil, nil, fmt.Errorf("failed program instantiation: %v", err)
}
res, val, det, err := run(prg, ast, false, input)
var dump *lib.Dump
if details {
dump = lib.NewDump(ast, det)
}
return res, val, dump, err
return res, val, dump, cov, err
}

func compile(src, root string, fold, details bool, libs ...cel.EnvOption) (cel.Program, *cel.Ast, error) {
func compile(src, root string, fold, details, coverage bool, libs ...cel.EnvOption) (cel.Program, *cel.Ast, *lib.Coverage, error) {
opts := append([]cel.EnvOption{
cel.Declarations(decls.NewVar(root, decls.Dyn)),
}, libs...)
env, err := cel.NewEnv(opts...)
if err != nil {
return nil, nil, fmt.Errorf("failed to create env: %v", err)
return nil, nil, nil, fmt.Errorf("failed to create env: %v", err)
}

ast, iss := env.Compile(src)
if iss.Err() != nil {
return nil, nil, fmt.Errorf("failed compilation: %v", iss.Err())
return nil, nil, nil, fmt.Errorf("failed compilation: %v", iss.Err())
}

if fold {
folder, err := cel.NewConstantFoldingOptimizer()
if err != nil {
return nil, nil, fmt.Errorf("failed folding optimization: %v", err)
return nil, nil, nil, fmt.Errorf("failed folding optimization: %v", err)
}
ast, iss = cel.NewStaticOptimizer(folder).Optimize(env, ast)
if iss.Err() != nil {
return nil, nil, fmt.Errorf("failed optimization: %v", iss.Err())
return nil, nil, nil, fmt.Errorf("failed optimization: %v", iss.Err())
}
}

var cov *lib.Coverage
var progOpts []cel.ProgramOption
if coverage {
cov = lib.NewCoverage(ast)
progOpts = []cel.ProgramOption{cov.ProgramOption()}
}
if details {
progOpts = []cel.ProgramOption{cel.EvalOptions(cel.OptTrackState)}
progOpts = append(progOpts, cel.EvalOptions(cel.OptTrackState))
}
prg, err := env.Program(ast, progOpts...)
if err != nil {
return nil, nil, fmt.Errorf("failed program instantiation: %v", err)
return nil, nil, nil, fmt.Errorf("failed program instantiation: %v", err)
}
return prg, ast, nil
return prg, ast, cov, nil
}

func run(prg cel.Program, ast *cel.Ast, fast bool, input interface{}) (string, any, *cel.EvalDetails, error) {
Expand Down
Loading

0 comments on commit ab2ae47

Please sign in to comment.