Repository: google/go-flow-levee
Pull request #189 (2020-11-13 14:36:44)

Visit every node when debugging
This PR fixes an issue where some debug graphs were being generated with edges missing. In some cases, almost no edges were being generated, so the resulting DOT graph was actually just a collection of isolated instruction nodes.

The crux of the issue was that the algorithm that traversed the SSA graph made no attempt to visit every node. It only started a traversal with the first instruction of the first block, which only works if the graph is connected. In our tests, most of the time, the graph is connected, so I only ran into this issue a handful of times despite using the graphs every time I have to debug something. When the issue was brought to my attention by @vinayakankugoyal, I decided it was time to do something about it.

To fix the issue, I completely removed the `debug/graph` package (which wasn't needed) and rewrote the rendering code to visit each instruction and add its referrers/operands.

I've added some tests with golden files, like we are currently using for the SSA rendering tests.

## Future improvements

A few ideas for future improvements:
* Add block numbers in the DOT output
* Also render closures

I think both of those would be nice to have, but not pressing at the moment. @vinayakankugoyal I think the second one could be useful to the work you're currently doing, feel free to tackle it.

- [x] Tests pass
- (N/A) [ ] Running against a large codebase such as [Kubernetes](https://github.com/kubernetes/kubernetes) does not error out.
- (N/A) [ ] Appropriate changes to README are included in PR


Patch:
diff --git a/.travis.yml b/.travis.yml
index 7588e10..14878f9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,7 +28,7 @@ script:
   )
 - >
   echo "Checking for license headers..." && (
-    FILES=$(grep -rL 'https://www.apache.org/licenses/LICENSE-2.0' cmd internal pkg | grep -vE ".ssa")
+    FILES=$(grep -rL 'https://www.apache.org/licenses/LICENSE-2.0' cmd internal pkg | grep -vE "(.ssa|.dot)")
     if [[ -n ${FILES} ]]; then
       echo "The following files are missing the Apache header:"
       echo ${FILES}
diff --git a/DEVELOPING.md b/DEVELOPING.md
new file mode 100644
index 0000000..4fbaed2
--- /dev/null
+++ b/DEVELOPING.md
@@ -0,0 +1,66 @@
+# Developing Go Flow Levee
+
+## Recommended Reading
+
+Having some knowledge of the following packages is helpful when developing Go Flow Levee:
+
+* [golang.org/x/tools/go/analysis](https://golang.org/x/tools/go/analysis)
+
+The `analysis` package provides a framework for developing Go static analyzers. A good overview of the framework is provided in the `doc.go` file. For concrete examples, the `passes` directory contains many analyzers. In that directory, the `findcall` analyzer is intended as a simple example. The `nilness` analyzer is particularly interesting for our purposes, since it uses the `ssa` package.
+
+* [golang.org/x/tools/go/ssa](https://golang.org/x/tools/go/ssa)
+
+The `ssa` package provides a [Static Single Assignment (SSA)](https://en.wikipedia.org/wiki/Static_single_assignment_form) form for Go code. This is the main code representation used by Go Flow Levee, as it provides a way to track the flow of data in a program. The `doc.go` file provides a good overview of the main concepts. The main types (e.g., instructions, values) are defined in `ssa.go`.
+
+* [golang.org/go/types](https://golang.org/pkg/go/types)
+
+The `types` package provides information about types in a Go program. The most relevant files for our uses are `api.go` and `object.go`. For a deeper understanding, there is a nice tutorial about using the `types` package [here](https://github.com/golang/example/tree/master/gotypes).
+
+* [golang.org/go/ast](https://golang.org/pkg/go/ast)
+
+The `ast` package provides the [Abstract Syntax Tree (AST)](https://en.wikipedia.org/wiki/Abstract_syntax_tree) of Go code. We use this package to answer questions about the structure of the code, e.g. "does this struct definition have tagged fields" (in the `fieldtags` analyzer). You can view a Go program's AST with `gotype -ast <file>`.
+
+Outside of those packages, we often refer to the [Go Spec](https://golang.org/ref/spec) to determine the proper way to handle various Go constructs.
+
+## Debugging
+
+The main analyzer depends heavily on the [golang.org/x/tools/ssa](https://pkg.go.dev/golang.org/x/tools/ssa) package. Being able to read the SSA code and visualize its graph can be very useful for debugging. In order to generate the SSA code and DOT ([graphviz](https://graphviz.org/)) source for every function in a test, run `go test <package> -debug`. Results are written to the `output` directory. You can generate a PDF from the DOT source using `dot -Tpdf <file> -o "$(basename <file> .dot).pdf"`.
+
+Currently, debugging is only supported for the `levee` analyzer. In order to add support for debugging in a new test, first add a debugging flag:
+```go
+var debugging bool = flag.Bool("debug", false, "run the debug analyzer")
+```
+Then add `debug.Analyzer` as a dependency of the analyzer being tested:
+```go
+if *debugging {
+	Analyzer.Requires = append(Analyzer.Requires, debug.Analyzer)
+}
+```
+
+In the `dot` output:
+* A **red** edge points to a **Referrer** of an `ssa.Node`
+* An **orange** edge points to an **Operand** of an `ssa.Node`
+* **Rectangle**-shaped nodes represent `ssa.Node`s that are both `ssa.Instruction`s and `ssa.Value`s
+* **Diamond**-shaped nodes represent `ssa.Node`s that are only `ssa.Instruction`s
+* **Ellipse**-shaped nodes represent `ssa.Node`s that are either only `ssa.Value`s, or are `ssa.Member`s.
+
+The function's control-flow graph (CFG) is also produced and written in a file named `<function-name>-cfg.dot`.
+
+## Source Code Headers
+
+Every file containing source code or data (e.g., YAML configuration files) must include
+the Apache header:
+
+    Copyright 2020 Google LLC
+
+    Licensed 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
+
+        https://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.
diff --git a/README.md b/README.md
index 7c9d0cd..584ad99 100644
--- a/README.md
+++ b/README.md
@@ -42,57 +42,14 @@ Since taint propagation is often used as a security safeguard, we care more deep
 
 For general bug reports (e.g. crashes), please open an issue [here](https://github.com/google/go-flow-levee/issues/new?template=bug_report.md).
 
-### Debugging
-
-The main analyzer depends heavily on the SSA package. Being able to read the SSA code and visualize its graph can be very useful for debugging. In order to generate the SSA code and DOT (graphviz) source for every function in a test, run `go test levee_test.go -debug`. Results are written to the `output` directory. You can generate a PDF from the DOT source using `dot -Tpdf <file> -o "$(basename <file> .dot).pdf"`.
-
-Currently, debugging is only supported for `levee_test.go`. In order to add support for debugging in a new test, first add a debugging flag:
-```go
-var debugging bool = flag.Bool("debug", false, "run the debug analyzer")
-```
-Then add `debug.Analyzer` as a dependency of the analyzer being tested:
-```go
-if *debugging {
-	Analyzer.Requires = append(Analyzer.Requires, debug.Analyzer)
-}
-```
-
-In the `dot` output:
-* A **red** edge points to a **Referrer** of an `ssa.Node`
-* An **orange** edges points to an **Operand** of an `ssa.Node`
-* **Rectangle**-shaped nodes represent `ssa.Node`s that are both `ssa.Instruction`s and `ssa.Value`s
-* **Diamond**-shaped nodes represent `ssa.Node`s that are only `ssa.Instruction`s
-* **Ellipse**-shaped nodes represent `ssa.Node`s that are either only `ssa.Value`s, or are `ssa.Member`s.
-
-The function's control-flow graph (CFG) is also produced and written in a file named `<function-name>-cfg.dot`.
-
-## Source Code Headers
-
-Every file containing source code must include copyright and license
-information. This includes any JS/CSS files that you might be serving out to
-browsers. (This is to help well-intentioned people avoid accidental copying that
-doesn't comply with the license.)
-
-Apache header:
-
-    Copyright 2020 Google LLC
-
-    Licensed 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
-
-        https://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.
-
 ## Contributing
 
 See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
 
+## Developing
+
+See [DEVELOPING.md](DEVELOPING.md) for details.
+
 ## Disclaimer
 
 This is not an officially supported Google product.
diff --git a/configuration/README.md b/configuration/README.md
index 0061b7c..786c983 100644
--- a/configuration/README.md
+++ b/configuration/README.md
@@ -13,14 +13,18 @@ For design details concerning value and instruction classification, see [/design
 
 Configuration is provided to `go-flow-levee` via YAML.
 
-Objects of interest are identified primarily via regexp. An empty regexp will match any string.
+Sources are identified by package, type, and field names.
+You may specify these with a combination of string literals or regexp.
+Use `Package`, `Type`, and `Field` to specify by string literal.
+Use `PackageRE`, `TypeRE`, and `FieldRE` to specify by regexp.
+If neither a literal nor a regexp is provided, a wildcard matcher is assumed.
+Providing both a literal and a regexp matcher is considered a misconfiguration and will error.
 
-Sources are identified via regexp according to package, type, and field names.
 ```yaml
 Sources:
-- PackageRE: "<package path regexp>"
-  TypeRE: "<type name regexp>"
-  FieldRE: "<field name regexp>"
+- Package: "full/package/path"
+  # Neither Type nor TypeRE are specified - match all type names
+  FieldRE: "Token|Password|Secret" 
 ```
 
 Sources may also be identified via field tags:
@@ -37,35 +41,40 @@ FieldTags:
   Val: source
 ```
 
-Sinks and sanitizers are identified via regexp according to package, method, and (optional) receiver name.
+Sinks and sanitizers are identified by package, method, and (if applicable) receiver name.
+As with source configuration, these may be specified by either a provided string literal or regexp.
+Use `Package`, `Receiver`, and `Method` to specify by string literal.
+Use `PackageRE`, `ReceiverRE`, and `MethodRE` to specify by regexp.
+If neither a literal nor a regexp is provided, a wildcard matcher is assumed.
+Providing both a literal and a regexp matcher is considered a misconfiguration and will error.
 
 ```yaml
 Sinks:
-- PackageRE: <package path regexp>
-  ReceiverRE: <type name regexp>
-  MethodRE: <method name regexp>
+- PackageRE:  ".*/sinks(/v1)?"  # Regexp match a collection of packages 
+  # Neither Receiver nor ReceiverRE is provided - match any (or no) receiver
+  Method: "Sink"  # Match only functions named exactly "Sink"
 Sanitizers:
-- PackageRE: <package path regexp>
-  ReceiverRE: <type name regexp>
-  MethodRE: <method name regexp>
+- # Neither Package nor PackageRE is provided - match any package
+  ReceiverRE: "^Safe"  # Match any receiver beginning with "Safe"
+  Method: "sanitize"  # Match methods named exactly "sanitize"
 ```
 
-Taint propagation is performed automatically and does not need to be explicitly configured.
+To explicitly match an empty string, such as top-level functions without a receiver, explicitly configure an empty string matcher, e.g., `Receiver: ""`.
 
-For matchers that accept a `ReceiverRE` regexp matcher, an unspecified string will match any (or no) receiver.
-To match only methods without any receiver (i.e., a top-level function), use the matcher `^$` to match an empty-string receiver name.
+Taint propagation is performed automatically and does not need to be explicitly configured.
 
 ### Restricting analysis scope
 
-Functions can be explicitly excluded from analysis using regexps,
+Functions can be explicitly excluded from analysis using string literals or regexps,
 constructed similarly to those used to identify sanitizers and sinks:
 ```yaml
 Exclude:
-- PathRE: "^myproject/mypackage$"
-  MethodRE: "^myfunction$"
+- Package: "myproject/mypackage"
+  MethodRE: "^my.*"
 ```
 
-The above will match the function `myfunction` from the `myproject/mypackage` package. It will also match a method named `myfunction` in the same package.
+The above will match any function beginning with "my" in the `myproject/mypackage` package.
+Since no receiver matcher was provided, it will match any method beginning with "my" bound to any (or no) receiver.
 
 As just two examples, this may be used to avoid analyzing test code, or to suppress "false positive" reports.
 
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index e9305f1..5e045a5 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -15,6 +15,7 @@
 package config
 
 import (
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io/ioutil"
@@ -120,31 +121,129 @@ func (c Config) IsSourceField(path, typeName, fieldName string) bool {
 	return false
 }
 
-// A sourceMatcher defines what types are or contain sources.
-// Within a given type, specific field access can be specified as the actual source data
-// via the fieldRE.
+type stringMatcher interface {
+	MatchString(string) bool
+}
+
+type literalMatcher string
+
+func (lm literalMatcher) MatchString(s string) bool {
+	return string(lm) == s
+}
+
+type vacuousMatcher struct{}
+
+func (vacuousMatcher) MatchString(string) bool {
+	return true
+}
+
+// Returns the first non-nil matcher.  If all are nil, returns a vacuousMatcher.
+func matcherFrom(lm *literalMatcher, r *regexp.Regexp) stringMatcher {
+	switch {
+	case lm != nil:
+		return lm
+	case r != nil:
+		return r
+	default:
+		return vacuousMatcher{}
+	}
+}
+
+// A sourceMatcher matches by package, type, and field.
+// Matching may be done against string literals Package, Type, Field,
+// or against regexp PackageRE, TypeRE, FieldRE.
 type sourceMatcher struct {
-	PackageRE regexp.Regexp
-	TypeRE    regexp.Regexp
-	FieldRE   regexp.Regexp
+	Package stringMatcher
+	Type    stringMatcher
+	Field   stringMatcher
+}
+
+// this type uses the default unmarshaler and mirrors configuration key-value pairs
+type rawSourceMatcher struct {
+	Package   *literalMatcher
+	Type      *literalMatcher
+	Field     *literalMatcher
+	PackageRE *regexp.Regexp
+	TypeRE    *regexp.Regexp
+	FieldRE   *regexp.Regexp
+}
+
+func (s *sourceMatcher) UnmarshalJSON(bytes []byte) error {
+	raw := rawSourceMatcher{}
+	if err := json.Unmarshal(bytes, &raw); err != nil {
+		return err
+	}
+
+	// validation: do not double-specify any attribute with literal and regexp
+	if raw.Package != nil && raw.PackageRE != nil {
+		return fmt.Errorf("expected only one of Package, PackageRE to be configured")
+	}
+	if raw.Type != nil && raw.TypeRE != nil {
+		return fmt.Errorf("expected only one of Type, TypeRE to be configured")
+	}
+	if raw.Field != nil && raw.FieldRE != nil {
+		return fmt.Errorf("expected only one of Field, FieldRE to be configured")
+	}
+
+	*s = sourceMatcher{
+		Package: matcherFrom(raw.Package, raw.PackageRE),
+		Type:    matcherFrom(raw.Type, raw.TypeRE),
+		Field:   matcherFrom(raw.Field, raw.FieldRE),
+	}
+	return nil
 }
 
 func (s sourceMatcher) MatchType(path, typeName string) bool {
-	return s.PackageRE.MatchString(path) && s.TypeRE.MatchString(typeName)
+	return s.Package.MatchString(path) && s.Type.MatchString(typeName)
 }
 
 func (s sourceMatcher) MatchField(path, typeName, fieldName string) bool {
-	return s.PackageRE.MatchString(path) && s.TypeRE.MatchString(typeName) && s.FieldRE.MatchString(fieldName)
+	return s.Package.MatchString(path) && s.Type.MatchString(typeName) && s.Field.MatchString(fieldName)
 }
 
 type funcMatcher struct {
-	PackageRE  regexp.Regexp
-	ReceiverRE regexp.Regexp
-	MethodRE   regexp.Regexp
+	Package  stringMatcher
+	Receiver stringMatcher
+	Method   stringMatcher
+}
+
+// this type uses the default unmarshaler and mirrors configuration key-value pairs
+type rawFuncMatcher struct {
+	Package    *literalMatcher
+	Receiver   *literalMatcher
+	Method     *literalMatcher
+	PackageRE  *regexp.Regexp
+	ReceiverRE *regexp.Regexp
+	MethodRE   *regexp.Regexp
+}
+
+func (fm *funcMatcher) UnmarshalJSON(bytes []byte) error {
+	raw := rawFuncMatcher{}
+	if err := json.Unmarshal(bytes, &raw); err != nil {
+		return err
+	}
+
+	// validation: do not double-specify any attribute with literal and regexp
+	if raw.Package != nil && raw.PackageRE != nil {
+		return fmt.Errorf("expected at most one of Package, PackageRE to be configured")
+	}
+	if raw.Receiver != nil && raw.ReceiverRE != nil {
+		return fmt.Errorf("expected at most one of Receiver, ReceiverRE to be configured")
+	}
+	if raw.Method != nil && raw.MethodRE != nil {
+		return fmt.Errorf("expected at most one of Method, MethodRE to be configured")
+	}
+
+	*fm = funcMatcher{
+		Package:  matcherFrom(raw.Package, raw.PackageRE),
+		Receiver: matcherFrom(raw.Receiver, raw.ReceiverRE),
+		Method:   matcherFrom(raw.Method, raw.MethodRE),
+	}
+	return nil
 }
 
 func (fm funcMatcher) MatchFunction(path, receiver, name string) bool {
-	return fm.PackageRE.MatchString(path) && fm.ReceiverRE.MatchString(receiver) && fm.MethodRE.MatchString(name)
+	return fm.Package.MatchString(path) && fm.Receiver.MatchString(receiver) && fm.Method.MatchString(name)
 }
 
 var readFileOnce sync.Once
diff --git a/internal/pkg/config/regexp/regexp.go b/internal/pkg/config/regexp/regexp.go
index 54a84a8..57252f6 100644
--- a/internal/pkg/config/regexp/regexp.go
+++ b/internal/pkg/config/regexp/regexp.go
@@ -26,6 +26,11 @@ type Regexp struct {
 	r *regexp.Regexp
 }
 
+func New(s string) (*Regexp, error) {
+	r, err := regexp.Compile(s)
+	return &Regexp{r: r}, err
+}
+
 // MatchString delegates matching to the regex package.
 func (mr *Regexp) MatchString(s string) bool {
 	return mr.r == nil || mr.r.MatchString(s)
diff --git a/internal/pkg/debug/graph/graph.go b/internal/pkg/debug/graph/graph.go
deleted file mode 100644
index 35edfb1..0000000
--- a/internal/pkg/debug/graph/graph.go
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright 2019 Google LLC
-//
-// Licensed 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
-//
-// https://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 graph defines an abstraction of the SSA graph that facilitates rendering.
-package graph
-
-import (
-	"log"
-
-	"golang.org/x/tools/go/ssa"
-)
-
-type relationship int
-
-const (
-	// Referrer represents a node that is referred to as per ssa.Value.Referrers
-	Referrer relationship = iota
-	// Operand represents a node that is an operand of an instruction as per ssa.Instr.Operands
-	Operand
-)
-
-// Node represents a node in the SSA graph, along with its relationship to a parent node.
-type Node struct {
-	// N is the ssa.Node wrapped by this type.
-	N ssa.Node
-	// R is the relationship between this node and its parent in the SSA graph.
-	R relationship
-}
-
-// FuncGraph represents the SSA graph for an ssa.Function.
-type FuncGraph struct {
-	// F is the function whose graph this is
-	F *ssa.Function
-	// Children is a mapping from each node to its children (referrers + operands)
-	Children map[ssa.Node][]Node
-	// visited is used while creating the graph to avoid needlessly revisiting nodes
-	visited map[ssa.Node]bool
-	// stack is used to perform a DFS on F's SSA graph
-	s stack
-}
-
-// New returns a new Graph constructed from a given function.
-func New(f *ssa.Function) *FuncGraph {
-	g := FuncGraph{
-		F:        f,
-		Children: map[ssa.Node][]Node{},
-		visited:  map[ssa.Node]bool{},
-	}
-	g.visitBlocks()
-	return &g
-}
-
-func (g *FuncGraph) visitBlocks() {
-	for _, b := range g.F.Blocks {
-		g.visit(b)
-	}
-}
-
-func (g *FuncGraph) visit(b *ssa.BasicBlock) {
-	// according to the ssa package docs, this should not happen,
-	// but we don't want a panic
-	if len(b.Instrs) == 0 {
-		return
-	}
-
-	n := b.Instrs[0].(ssa.Node)
-	g.visited[n] = true
-	g.s.push(n)
-	for len(g.s) > 0 {
-		current := g.s.pop()
-		if current == nil {
-			break
-		}
-		g.visitOperands(current)
-		g.visitReferrers(current)
-	}
-}
-
-func (g *FuncGraph) visitOperands(n ssa.Node) {
-	operands := n.Operands(nil)
-	if operands == nil {
-		return
-	}
-	for _, o := range operands {
-		on, ok := (*o).(ssa.Node)
-		if !ok {
-			continue
-		}
-		g.addOperand(n, on)
-		g.visitNode(on)
-	}
-}
-
-func (g *FuncGraph) visitReferrers(n ssa.Node) {
-	if n.Referrers() == nil {
-		return
-	}
-	for _, ref := range *n.Referrers() {
-		rn := ref.(ssa.Node)
-		g.addReferrer(n, rn)
-		g.visitNode(rn)
-	}
-}
-
-func (g *FuncGraph) visitNode(n ssa.Node) {
-	if g.visited[n] {
-		return
-	}
-	g.visited[n] = true
-	g.s.push(n)
-}
-
-func (g *FuncGraph) addReferrer(current, referrer ssa.Node) {
-	g.Children[current] = append(g.Children[current], Node{N: referrer, R: Referrer})
-}
-
-func (g *FuncGraph) addOperand(current, operand ssa.Node) {
-	g.Children[current] = append(g.Children[current], Node{N: operand, R: Operand})
-}
-
-type stack []ssa.Node
-
-func (s *stack) pop() ssa.Node {
-	if len(*s) == 0 {
-		log.Println("tried to pop from empty stack")
-	}
-	popped := (*s)[len(*s)-1]
-	*s = (*s)[:len(*s)-1]
-	return popped
-}
-
-func (s *stack) push(n ssa.Node) {
-	*s = append(*s, n)
-}
diff --git a/internal/pkg/debug/render/dot.go b/internal/pkg/debug/render/dot.go
index 8fa208c..a3a8135 100644
--- a/internal/pkg/debug/render/dot.go
+++ b/internal/pkg/debug/render/dot.go
@@ -18,23 +18,18 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/google/go-flow-levee/internal/pkg/debug/graph"
 	"github.com/google/go-flow-levee/internal/pkg/debug/node"
 	"golang.org/x/tools/go/ssa"
 )
 
 // DOT produces DOT source code representing the SSA graph for a function.
 func DOT(f *ssa.Function) string {
-	return renderDOT(graph.New(f))
-}
-
-func renderDOT(g *graph.FuncGraph) string {
-	return (&renderer{strings.Builder{}, g}).Render()
+	return (&renderer{strings.Builder{}, f}).Render()
 }
 
 type renderer struct {
 	strings.Builder
-	*graph.FuncGraph
+	f *ssa.Function
 }
 
 func (r *renderer) Render() string {
@@ -46,45 +41,51 @@ func (r *renderer) Render() string {
 }
 
 func (r *renderer) init() {
-	_, _ = r.WriteString("digraph {\n")
+	r.WriteString("digraph {\n")
 }
 
 func (r *renderer) writeSubgraphs() {
-	for bi, b := range r.F.Blocks {
-		_, _ = r.WriteString(fmt.Sprintf("\tsubgraph cluster_%d {\n\t\tcolor=black;\n\t\tlabel=%q;\n", bi, b.Comment))
+	for bi, b := range r.f.Blocks {
+		r.WriteString(fmt.Sprintf("\tsubgraph cluster_%d {\n\t\tcolor=black;\n\t\tlabel=%q;\n", bi, b.Comment))
 		for _, i := range b.Instrs {
 			n := i.(ssa.Node)
-			_, _ = r.WriteString(fmt.Sprintf("\t\t%q [shape=%s];\n", renderNode(n), nodeShape(n)))
+			r.WriteString(fmt.Sprintf("\t\t%q [shape=%s];\n", renderNode(n), nodeShape(n)))
 		}
-		_, _ = r.WriteString("\t}\n")
+		r.WriteString("\t}\n")
 	}
 }
 
 func (r *renderer) writeEdges() {
-	for from, children := range r.Children {
-		for _, to := range children {
-			switch to.R {
-			case graph.Referrer:
-				r.addReferrer(from, to.N)
-			case graph.Operand:
-				r.addOperand(from, to.N)
-			}
+	for _, b := range r.f.Blocks {
+		for _, i := range b.Instrs {
+			r.writeReferrers(i.(ssa.Node))
+			r.writeOperands(i.(ssa.Node))
 		}
 	}
 }
 
-func (r *renderer) addReferrer(n ssa.Node, ref ssa.Node) {
-	// Red as in R-eferrer
-	r.addEdge(n, ref, "red")
+func (r *renderer) writeReferrers(n ssa.Node) {
+	if n.Referrers() == nil {
+		return
+	}
+	for _, ref := range *n.Referrers() {
+		// Red as in R-eferrer
+		r.writeEdge(n, ref.(ssa.Node), "red")
+	}
 }
 
-func (r *renderer) addOperand(n ssa.Node, op ssa.Node) {
-	// Orange as in O-perand
-	r.addEdge(n, op, "orange")
+func (r *renderer) writeOperands(n ssa.Node) {
+	for _, o := range n.Operands(nil) {
+		if *o == nil {
+			continue
+		}
+		// Orange as in O-perand
+		r.writeEdge(n, (*o).(ssa.Node), "orange")
+	}
 }
 
-func (r *renderer) addEdge(from ssa.Node, to ssa.Node, color string) {
-	_, _ = r.WriteString(fmt.Sprintf("\t%q -> %q [color=%s];\n", renderNode(from), renderNode(to), color))
+func (r *renderer) writeEdge(from ssa.Node, to ssa.Node, color string) {
+	r.WriteString(fmt.Sprintf("\t%q -> %q [color=%s];\n", renderNode(from), renderNode(to), color))
 }
 
 func renderNode(n ssa.Node) string {
@@ -92,7 +93,7 @@ func renderNode(n ssa.Node) string {
 }
 
 func (r *renderer) finish() {
-	_, _ = r.WriteString("}\n")
+	r.WriteString("}\n")
 }
 
 func nodeShape(n ssa.Node) string {
diff --git a/internal/pkg/source/source.go b/internal/pkg/source/source.go
index fc4731c..bf8614a 100644
--- a/internal/pkg/source/source.go
+++ b/internal/pkg/source/source.go
@@ -75,21 +75,19 @@ func New(in ssa.Node, config classifier) *Source {
 		marked: make(map[ssa.Node]bool),
 		config: config,
 	}
-	s.dfs(in, map[*ssa.BasicBlock]int{}, nil)
+	s.dfs(in, map[*ssa.BasicBlock]int{}, nil, false)
 	return s
 }
 
 // dfs performs Depth-First-Search on the def-use graph of the input Source.
 // While traversing the graph we also look for potential sanitizers of this Source.
 // If the Source passes through a sanitizer, dfs does not continue through that Node.
-func (s *Source) dfs(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
-	if s.marked[n] {
-		return
-	}
-	// booleans can't meaningfully be tainted
-	if isBoolean(n) {
+func (s *Source) dfs(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock, isReferrer bool) {
+	if s.shouldNotVisit(n, maxInstrReached, lastBlockVisited, isReferrer) {
 		return
 	}
+	s.preOrder = append(s.preOrder, n)
+	s.marked[n] = true
 
 	mirCopy := map[*ssa.BasicBlock]int{}
 	for m, i := range maxInstrReached {
@@ -97,82 +95,165 @@ func (s *Source) dfs(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBl
 	}
 
 	if instr, ok := n.(ssa.Instruction); ok {
-		// If the referrer is in a different block from the one we last visited,
-		// and it can't be reached from the block we are visiting, then stop visiting.
-		if ib := instr.Block(); lastBlockVisited != nil &&
-			ib != lastBlockVisited &&
-			!s.canReach(lastBlockVisited, ib) {
+		instrIndex, ok := indexInBlock(instr)
+		if !ok {
 			return
 		}
 
-		b := instr.Block()
-		if i, ok := indexInBlock(instr); ok && mirCopy[b] < i {
-			mirCopy[b] = i
+		if mirCopy[instr.Block()] < instrIndex {
+			mirCopy[instr.Block()] = instrIndex
 		}
-		lastBlockVisited = b
-	}
-	s.preOrder = append(s.preOrder, n)
-	s.marked[n] = true
 
-	s.visitReferrers(n, mirCopy, lastBlockVisited)
+		lastBlockVisited = instr.Block()
+	}
 
-	s.visitOperands(n, n.Operands(nil), mirCopy, lastBlockVisited)
+	s.visit(n, mirCopy, lastBlockVisited)
 }
 
-func (s *Source) visitReferrers(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
-	referrers := s.referrersToVisit(n, maxInstrReached)
+func (s *Source) shouldNotVisit(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock, isReferrer bool) bool {
+	if s.marked[n] {
+		return true
+	}
 
-	for _, r := range referrers {
-		switch v := r.(type) {
-		case *ssa.Call:
-			if callee := v.Call.StaticCallee(); callee != nil && s.config.IsSanitizer(utils.DecomposeFunction(callee)) {
-				s.sanitizers = append(s.sanitizers, &sanitizer.Sanitizer{Call: v})
-			}
+	// booleans can't meaningfully be tainted
+	if isBoolean(n) {
+		return true
+	}
+
+	if instr, ok := n.(ssa.Instruction); ok {
+		instrIndex, ok := indexInBlock(instr)
+		if !ok {
+			return true
 		}
 
-		s.dfs(r.(ssa.Node), maxInstrReached, lastBlockVisited)
+		// If the referrer is in a different block from the one we last visited,
+		// and it can't be reached from the block we are visiting, then stop visiting.
+		if lastBlockVisited != nil && instr.Block() != lastBlockVisited && !s.canReach(lastBlockVisited, instr.Block()) {
+			return true
+		}
+
+		// If this call's index is lower than the highest seen so far in its block,
+		// then this call is "in the past". If this call is a referrer,
+		// then we would be propagating taint backwards in time, so stop traversing.
+		// (If the call is an operand, then it is being used as a value, so it does
+		// not matter when the call occurred.)
+		if _, ok := instr.(*ssa.Call); ok && instrIndex < maxInstrReached[instr.Block()] && isReferrer {
+			return true
+		}
 	}
+
+	return false
 }
 
-// referrersToVisit produces a filtered list of Referrers for an ssa.Node.
-// Specifically, we want to avoid referrers that shouldn't be visited, e.g.
-// because they would not be reachable in an actual execution of the program.
-func (s *Source) referrersToVisit(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int) (referrers []ssa.Instruction) {
-	if n.Referrers() == nil {
+func (s *Source) visit(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
+	if s.node == n {
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
 		return
 	}
-	for _, r := range *n.Referrers() {
-		if c, ok := r.(*ssa.Call); ok {
-			// This is to avoid attaching calls where the source is the receiver, ex:
-			// core.Sinkf("Source id: %v", wrapper.Source.GetID())
-			if recv := c.Call.Signature().Recv(); recv != nil && s.config.IsSourceType(utils.DecomposeType(utils.Dereference(recv.Type()))) {
-				continue
-			}
 
-			// If this call's index is lower than the highest in its block,
-			// then this call is "in the past" and we should stop traversing.
-			i, ok := indexInBlock(r)
-			if !ok {
-				continue
-			}
-			if i < maxInstrReached[r.Block()] {
-				continue
-			}
+	switch t := n.(type) {
+	case *ssa.Alloc:
+		// An Alloc represents the allocation of space for a variable. If a Node is an Alloc,
+		// and the thing being allocated is not an array, then either:
+		// a) it is a Source value, in which case it will get its own traversal when sourcesFromBlocks
+		//    finds this Alloc
+		// b) it is not a Source value, in which case we should not visit it.
+		// However, if the Alloc is an array, then that means the source that we are visiting from
+		// is being placed into an array, slice or varags, so we do need to keep visiting.
+		if _, isArray := utils.Dereference(t.Type()).(*types.Array); isArray {
+			s.visitReferrers(n, maxInstrReached, lastBlockVisited)
 		}
 
-		if fa, ok := r.(*ssa.FieldAddr); ok {
-			deref := utils.Dereference(fa.X.Type())
-			typPath, typName := utils.DecomposeType(deref)
-			fieldName := utils.FieldName(fa)
+	case *ssa.Call:
+		if callee := t.Call.StaticCallee(); callee != nil && s.config.IsSanitizer(utils.DecomposeFunction(callee)) {
+			s.sanitizers = append(s.sanitizers, &sanitizer.Sanitizer{Call: t})
+		}
 
-			if !s.config.IsSourceField(typPath, typName, fieldName) {
-				continue
-			}
+		// This is to avoid attaching calls where the source is the receiver, ex:
+		// core.Sinkf("Source id: %v", wrapper.Source.GetID())
+		if recv := t.Call.Signature().Recv(); recv != nil && s.config.IsSourceType(utils.DecomposeType(utils.Dereference(recv.Type()))) {
+			return
 		}
 
-		referrers = append(referrers, r)
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+		s.visitOperands(n, maxInstrReached, lastBlockVisited)
+
+	case *ssa.FieldAddr:
+		deref := utils.Dereference(t.X.Type())
+		typPath, typName := utils.DecomposeType(deref)
+		fieldName := utils.FieldName(t)
+		if !s.config.IsSourceField(typPath, typName, fieldName) {
+			return
+		}
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+		s.visitOperands(n, maxInstrReached, lastBlockVisited)
+
+	// Everything but the actual integer Index should be visited.
+	case *ssa.Index:
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+		s.dfs(t.X.(ssa.Node), maxInstrReached, lastBlockVisited, false)
+
+	// The actual integer Index should not be visited.
+	// Everything but the actual integer Index should be visited.
+	case *ssa.IndexAddr:
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+		s.dfs(t.X.(ssa.Node), maxInstrReached, lastBlockVisited, false)
+
+	// Only the Map itself can be tainted by an Update.
+	// The Key can't be tainted.
+	// The Value can propagate taint to the Map, but not receive it.
+	// MapUpdate has no referrers, it is only an Instruction, not a Value.
+	case *ssa.MapUpdate:
+		s.dfs(t.Map.(ssa.Node), maxInstrReached, lastBlockVisited, false)
+
+	// The only Operand that can be tainted by a Send is the Chan.
+	// The Value can propagate taint to the Chan, but not receive it.
+	// Send has no referrers, it is only an Instruction, not a Value.
+	case *ssa.Send:
+		s.dfs(t.Chan.(ssa.Node), maxInstrReached, lastBlockVisited, false)
+
+	// These nodes' operands should not be visited, because they can only receive
+	// taint from their operands, not propagate taint to them.
+	case *ssa.BinOp, *ssa.ChangeInterface, *ssa.ChangeType, *ssa.Convert, *ssa.Extract, *ssa.MakeChan, *ssa.MakeMap, *ssa.MakeSlice, *ssa.Phi, *ssa.Range, *ssa.Slice, *ssa.UnOp:
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+
+	// These nodes don't have operands; they are Values, not Instructions.
+	case *ssa.Const, *ssa.FreeVar, *ssa.Global, *ssa.Lookup, *ssa.Parameter:
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+
+	// These nodes don't have referrers; they are Instructions, not Values.
+	case *ssa.Go, *ssa.Store:
+		s.visitOperands(n, maxInstrReached, lastBlockVisited)
+
+	// These nodes are both Instructions and Values, and currently have no special restrictions.
+	case *ssa.Field, *ssa.MakeInterface, *ssa.Select, *ssa.TypeAssert:
+		s.visitReferrers(n, maxInstrReached, lastBlockVisited)
+		s.visitOperands(n, maxInstrReached, lastBlockVisited)
+
+	// These nodes cannot propagate taint.
+	case *ssa.Builtin, *ssa.DebugRef, *ssa.Defer, *ssa.Function, *ssa.If, *ssa.Jump, *ssa.MakeClosure, *ssa.Next, *ssa.Panic, *ssa.Return, *ssa.RunDefers:
+
+	default:
+		fmt.Printf("unexpected node received: %T %v; please report this issue\n", n, n)
+	}
+}
+
+func (s *Source) visitReferrers(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
+	if n.Referrers() == nil {
+		return
+	}
+	for _, r := range *n.Referrers() {
+		s.dfs(r.(ssa.Node), maxInstrReached, lastBlockVisited, true)
+	}
+}
+
+func (s *Source) visitOperands(n ssa.Node, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
+	for _, o := range n.Operands(nil) {
+		if *o == nil {
+			continue
+		}
+		s.dfs((*o).(ssa.Node), maxInstrReached, lastBlockVisited, false)
 	}
-	return referrers
 }
 
 func (s *Source) canReach(start *ssa.BasicBlock, dest *ssa.BasicBlock) bool {
@@ -198,47 +279,6 @@ func (s *Source) canReach(start *ssa.BasicBlock, dest *ssa.BasicBlock) bool {
 	return false
 }
 
-func (s *Source) visitOperands(from ssa.Node, operands []*ssa.Value, maxInstrReached map[*ssa.BasicBlock]int, lastBlockVisited *ssa.BasicBlock) {
-	// Do not visit Operands if the current node is an Extract.
-	// This is to avoid incorrectly tainting non-Source values that are
-	// produced by an Instruction that has a Source among the values it
-	// produces, e.g. a call to a function with a signature like:
-	// func NewSource() (*core.Source, error)
-	// Which leads to a flow like:
-	// Extract (*core.Source) --> Call (NewSource) --> error
-	if _, ok := from.(*ssa.Extract); ok {
-		return
-	}
-
-	for _, o := range operands {
-		n, ok := (*o).(ssa.Node)
-		if !ok {
-			continue
-		}
-
-		// An Alloc represents the allocation of space for a variable. If a Node is an Alloc,
-		// and the thing being allocated is not an array, then either:
-		// a) it is a Source value, in which case it will get its own traversal when sourcesFromBlocks
-		//    finds this Alloc
-		// b) it is not a Source value, in which case we should not visit it.
-		// However, if the Alloc is an array, then that means the source that we are visiting from
-		// is being placed into an array, slice or varags, so we do need to keep visiting.
-		if al, isAlloc := (*o).(*ssa.Alloc); isAlloc {
-			if _, isArray := utils.Dereference(al.Type()).(*types.Array); !isArray {
-				continue
-			}
-		}
-
-		// Don't traverse to the key in a lookup.
-		// For example, if a map is tainted, looking up a value in the map
-		// doesn't taint the key, so we shouldn't traverse to the key.
-		if look, ok := from.(*ssa.Lookup); ok && *o == look.Index {
-			continue
-		}
-		s.dfs(n, maxInstrReached, lastBlockVisited)
-	}
-}
-
 // compress removes the elements from the graph that are not required by the
 // taint-propagation analysis. Concretely, only propagators, sanitizers and
 // sinks should constitute the output. Since, we already know what the source
@@ -255,10 +295,6 @@ func (s *Source) compress() []ssa.Node {
 	return compressed
 }
 
-func (s *Source) RefersTo(n ssa.Node) bool {
-	return s.HasPathTo(n)
-}
-
 // HasPathTo returns true when a Node is part of declaration-use graph.
 func (s *Source) HasPathTo(n ssa.Node) bool {
 	return s.marked[n]
@@ -374,7 +410,7 @@ func sourcesFromBlocks(fn *ssa.Function, conf classifier) []*Source {
 				}
 
 			// source obtained through a field or an index operation
-			case *ssa.Field, *ssa.FieldAddr, *ssa.IndexAddr, *ssa.Lookup:
+			case *ssa.Field, *ssa.FieldAddr, *ssa.Index, *ssa.IndexAddr, *ssa.Lookup:
 
 			// source chan or map (arrays and slices have regular Allocs)
 			case *ssa.MakeMap, *ssa.MakeChan:


Test patch:
diff --git a/internal/pkg/config/matcher_test.go b/internal/pkg/config/matcher_test.go
new file mode 100644
index 0000000..38d91e4
--- /dev/null
+++ b/internal/pkg/config/matcher_test.go
@@ -0,0 +1,369 @@
+// Copyright 2020 Google LLC
+//
+// Licensed 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
+//
+// https://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 config
+
+import (
+	"testing"
+
+	"github.com/google/go-flow-levee/internal/pkg/config/regexp"
+	"sigs.k8s.io/yaml"
+)
+
+func TestFuncMatcherUnmarshaling(t *testing.T) {
+	testCases := []struct {
+		desc, yaml        string
+		shouldErrorOnLoad bool
+	}{
+		{
+			desc: "Unmarshaling is strict",
+			yaml: `
+Blahblah: foo
+PackageRE: bar`,
+			shouldErrorOnLoad: false, // TODO true
+		},
+		{
+			desc: "Malformed YAML errors gracefully",
+			yaml: `
+PackageRE: "No ending quote`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Malformed regexp errors gracefully",
+			yaml: `
+PackageRE: "(?:NoEndingParen"`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Package and PackageRE",
+			yaml: `
+Package: foo
+PackageRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Receiver and ReceiverRE",
+			yaml: `
+Receiver: foo
+ReceiverRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Field and FieldRE",
+			yaml: `
+Method: foo
+MethodRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			fm := funcMatcher{}
+			err := yaml.UnmarshalStrict([]byte(tc.yaml), &fm)
+
+			if (err != nil) != tc.shouldErrorOnLoad {
+				t.Errorf("error expectation = %v, but got err=%v", tc.shouldErrorOnLoad, err)
+			}
+		})
+	}
+}
+
+func TestFuncMatcherMatching(t *testing.T) {
+	testCases := []struct {
+		desc, yaml       string
+		path, recv, name string
+		shouldMatch      bool
+	}{
+		{
+			desc: "Literal foo.bar should match foo.bar; no receiver arg",
+			yaml: `
+Package: foo
+Method: bar`,
+			path:        "foo",
+			recv:        "",
+			name:        "bar",
+			shouldMatch: true,
+		},
+		{
+			desc: "Literal foo.bar should match foo.(baz).bar; no receiver arg",
+			yaml: `
+Package: foo
+Method: bar`,
+			path:        "foo",
+			recv:        "baz",
+			name:        "bar",
+			shouldMatch: true,
+		},
+		{
+			desc: "Literal foo.bar should NOT match foodstuff.bar; no receiver arg",
+			yaml: `
+Package: foo
+Method: bar`,
+			path:        "foodstuff",
+			recv:        "",
+			name:        "bar",
+			shouldMatch: false,
+		},
+		{
+			desc: "Mixed regexp and literal matchers are permitted - positive case",
+			yaml: `
+PackageRE: foo
+Receiver: baz
+Method: bar`,
+			path:        "foodstuff",
+			recv:        "baz",
+			name:        "bar",
+			shouldMatch: true,
+		},
+		{
+			desc: "Mixed regexp and literal matchers are permitted - negative case",
+			yaml: `
+PackageRE: foo
+Receiver: baz
+Method: bar`,
+			path:        "foodstuff",
+			recv:        "bazinga",
+			name:        "bar",
+			shouldMatch: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			fm := funcMatcher{}
+			if err := yaml.UnmarshalStrict([]byte(tc.yaml), &fm); err != nil {
+				t.Errorf("Unexpected error unmarshalling funcMatcher: %v", err)
+			}
+
+			if tc.shouldMatch != fm.MatchFunction(tc.path, tc.recv, tc.name) {
+				t.Errorf("MatchFunction(%q, %q, %q) = %v; got %v", tc.path, tc.recv, tc.name, tc.shouldMatch, !tc.shouldMatch)
+			}
+		})
+	}
+}
+
+func TestSourceMatcherUnmarshaling(t *testing.T) {
+	testCases := []struct {
+		desc, yaml        string
+		shouldErrorOnLoad bool
+	}{
+		{
+			desc: "Unmarshaling is strict",
+			yaml: `
+Blahblah: foo
+PackageRE: bar`,
+			shouldErrorOnLoad: false, // TODO true
+		},
+		{
+			desc: "Malformed YAML errors gracefully",
+			yaml: `
+PackageRE: "No ending quote`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Malformed regexp errors gracefully",
+			yaml: `
+PackageRE: "(?:NoEndingParen"`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Package and PackageRE",
+			yaml: `
+Package: foo
+PackageRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Type and TypeRE",
+			yaml: `
+Type: foo
+TypeRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+		{
+			desc: "Do not permit both Field and FieldRE",
+			yaml: `
+Field: foo
+FieldRE: bar`,
+			shouldErrorOnLoad: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			sm := sourceMatcher{}
+			err := yaml.UnmarshalStrict([]byte(tc.yaml), &sm)
+
+			if (err != nil) != tc.shouldErrorOnLoad {
+				t.Errorf("error expectation = %v, but got err=%v", tc.shouldErrorOnLoad, err)
+			}
+		})
+	}
+}
+
+func TestSourceMatcherMatching(t *testing.T) {
+	testCases := []struct {
+		desc, yaml           string
+		path, typ, fieldName string
+		shouldMatchType      bool
+		shouldMatchField     bool
+	}{
+		{
+			desc: "Literal foo.bar (no field args) should match foo.bar and foo.bar.baz",
+			yaml: `
+Package: foo
+Type: bar`,
+			path:             "foo",
+			typ:              "bar",
+			fieldName:        "baz",
+			shouldMatchType:  true,
+			shouldMatchField: true,
+		},
+		{
+			desc: "Literal foo.bar (no field args) should NOT match foodstuff.bar or foodstuff.bar.baz",
+			yaml: `
+Package: foo
+Type: bar`,
+			path:             "foodstuff",
+			typ:              "bar",
+			fieldName:        "baz",
+			shouldMatchType:  false,
+			shouldMatchField: false,
+		},
+		{
+			desc: "Regexp foo.bar (no field args) should match foodstuff.bar and foodstuff.bar.baz",
+			yaml: `
+PackageRE: foo
+TypeRE: bar`,
+			path:             "foodstuff",
+			typ:              "bar",
+			fieldName:        "baz",
+			shouldMatchType:  true,
+			shouldMatchField: true,
+		},
+		{
+			desc: "Mixed regexp and literal matchers are permitted",
+			yaml: `
+PackageRE: foo
+Type: bar
+Field: baz`,
+			path:             "foodstuff",
+			typ:              "bar",
+			fieldName:        "baz",
+			shouldMatchType:  true,
+			shouldMatchField: true,
+		},
+		{
+			desc: "Literal foo.bar.qux should MatchType foo.bar but not MatchField foo.bar.baz",
+			yaml: `
+PackageRE: foo
+Type: bar
+Field: qux`,
+			path:             "foodstuff",
+			typ:              "bar",
+			fieldName:        "baz",
+			shouldMatchType:  true,
+			shouldMatchField: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			sm := sourceMatcher{}
+			if err := yaml.UnmarshalStrict([]byte(tc.yaml), &sm); err != nil {
+				t.Errorf("Unexpected error unmarshalling sourceMatcher: %v", err)
+			}
+
+			if tc.shouldMatchType != sm.MatchType(tc.path, tc.typ) {
+				t.Errorf("MatchType(%q, %q) = %v; got %v", tc.path, tc.typ, tc.shouldMatchType, !tc.shouldMatchType)
+			}
+			if tc.shouldMatchField != sm.MatchField(tc.path, tc.typ, tc.fieldName) {
+				t.Errorf("MatchField(%q, %q, %q) = %v; got %v", tc.path, tc.typ, tc.fieldName, tc.shouldMatchType, !tc.shouldMatchType)
+			}
+		})
+	}
+}
+
+func TestMatcherTypes(t *testing.T) {
+	testCases := []struct {
+		desc        string
+		matcher     stringMatcher
+		s           string
+		shouldMatch bool
+	}{
+		{
+			desc:        "literal matcher foo == foo",
+			matcher:     literalMatcher("foo"),
+			s:           "foo",
+			shouldMatch: true,
+		},
+		{
+			desc:        "literal matcher foo != food",
+			matcher:     literalMatcher("foo"),
+			s:           "food",
+			shouldMatch: false,
+		},
+		{
+			desc:        "literal matcher foo != bar",
+			matcher:     literalMatcher("foo"),
+			s:           "bar",
+			shouldMatch: false,
+		},
+		{
+			desc:        "regexp matcher /foo/ matches foo",
+			matcher:     func() stringMatcher { r, _ := regexp.New("foo"); return r }(),
+			s:           "foo",
+			shouldMatch: true,
+		},
+		{
+			desc:        "regexp matcher /foo/ matches food",
+			matcher:     func() stringMatcher { r, _ := regexp.New("foo"); return r }(),
+			s:           "food",
+			shouldMatch: true,
+		},
+		{
+			desc:        "regexp matcher /foo/ does not match bar",
+			matcher:     func() stringMatcher { r, _ := regexp.New("foo"); return r }(),
+			s:           "bar",
+			shouldMatch: false,
+		},
+		{
+			desc:        "vacuous matcher matches foo",
+			matcher:     vacuousMatcher{},
+			s:           "foo",
+			shouldMatch: true,
+		},
+		{
+			desc:        "vacuous matcher matches food",
+			matcher:     vacuousMatcher{},
+			s:           "food",
+			shouldMatch: true,
+		},
+		{
+			desc:        "vacuous matcher matches bar",
+			matcher:     vacuousMatcher{},
+			s:           "bar",
+			shouldMatch: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			if tc.matcher.MatchString(tc.s) != tc.shouldMatch {
+				t.Errorf("Expected matcher (%T) %v to return MatchString(%q) == %v, got %v", tc.matcher, tc.matcher, tc.s, tc.shouldMatch, !tc.shouldMatch)
+			}
+		})
+	}
+}
diff --git a/internal/pkg/debug/render/ssa_test.go b/internal/pkg/debug/render/render_test.go
similarity index 78%
rename from internal/pkg/debug/render/ssa_test.go
rename to internal/pkg/debug/render/render_test.go
index 86f2270..a4fe82b 100644
--- a/internal/pkg/debug/render/ssa_test.go
+++ b/internal/pkg/debug/render/render_test.go
@@ -30,7 +30,16 @@ import (
 	"golang.org/x/tools/go/ssa/ssautil"
 )
 
+func TestDOT(t *testing.T) {
+	testGoldenFiles(t, DOT, "DOT", ".dot")
+}
+
 func TestSSA(t *testing.T) {
+	testGoldenFiles(t, SSA, "SSA", ".ssa")
+}
+
+// testGoldenFiles tests the output of a rendering function against the golden files under testdata.
+func testGoldenFiles(t *testing.T, fn func(f *ssa.Function) string, fnName, ext string) {
 	testdata, err := filepath.Abs("testdata")
 	if err != nil {
 		t.Fatal(err)
@@ -38,16 +47,17 @@ func TestSSA(t *testing.T) {
 	testfile := filepath.Join(testdata, "tests.go")
 	ssaFuncs := extractSSAFuncs(t, testfile)
 	for _, f := range ssaFuncs {
-		bytes, err := ioutil.ReadFile(filepath.Join(testdata, f.Name()) + ".ssa")
+		goldenFilename := filepath.Join(testdata, f.Name()) + ext
+		bytes, err := ioutil.ReadFile(goldenFilename)
 		if err != nil {
 			t.Fatal(err)
 		}
 		want := string(bytes)
 
-		got := SSA(f)
+		got := fn(f)
 
 		if diff := cmp.Diff(want, got); diff != "" {
-			t.Errorf("render.SSA(%s) diff (-want +got):\n%s", f.Name(), diff)
+			t.Errorf("render.%s(%s) diff (-want +got):\n%s", fnName, f.Name(), diff)
 		}
 	}
 }
diff --git a/internal/pkg/debug/render/testdata/TestClosure.dot b/internal/pkg/debug/render/testdata/TestClosure.dot
new file mode 100644
index 0000000..25addc7
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestClosure.dot
@@ -0,0 +1,10 @@
+digraph {
+	subgraph cluster_0 {
+		color=black;
+		label="entry";
+		"t0 = TestClosure$1(0:int)\n(Call)" [shape=rectangle];
+		"return\n(Return)" [shape=diamond];
+	}
+	"t0 = TestClosure$1(0:int)\n(Call)" -> "TestClosure$1\n(Function)" [color=orange];
+	"t0 = TestClosure$1(0:int)\n(Call)" -> "0:int\n(Const)" [color=orange];
+}
diff --git a/internal/pkg/debug/render/testdata/TestDisconnected.dot b/internal/pkg/debug/render/testdata/TestDisconnected.dot
new file mode 100644
index 0000000..c0f96f2
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestDisconnected.dot
@@ -0,0 +1,52 @@
+digraph {
+	subgraph cluster_0 {
+		color=black;
+		label="entry";
+		"jump 3\n(Jump)" [shape=diamond];
+	}
+	subgraph cluster_1 {
+		color=black;
+		label="for.body";
+		"t0 = t4 - 1:int\n(BinOp)" [shape=rectangle];
+		"t1 = t0 + 1:int\n(BinOp)" [shape=rectangle];
+		"jump 3\n(Jump)" [shape=diamond];
+	}
+	subgraph cluster_2 {
+		color=black;
+		label="for.done";
+		"t2 = \"error: \":string + \"unreachable code\":string\n(BinOp)" [shape=rectangle];
+		"t3 = fmt.Printf(t2, nil:[]interface{}...)\n(Call)" [shape=rectangle];
+		"return\n(Return)" [shape=diamond];
+	}
+	subgraph cluster_3 {
+		color=black;
+		label="for.loop";
+		"t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" [shape=rectangle];
+		"t5 = 1:int * 2:int\n(BinOp)" [shape=rectangle];
+		"t6 = t4 < t5\n(BinOp)" [shape=rectangle];
+		"if t6 goto 1 else 2\n(If)" [shape=diamond];
+	}
+	"t0 = t4 - 1:int\n(BinOp)" -> "t1 = t0 + 1:int\n(BinOp)" [color=red];
+	"t0 = t4 - 1:int\n(BinOp)" -> "t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" [color=orange];
+	"t0 = t4 - 1:int\n(BinOp)" -> "1:int\n(Const)" [color=orange];
+	"t1 = t0 + 1:int\n(BinOp)" -> "t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" [color=red];
+	"t1 = t0 + 1:int\n(BinOp)" -> "t0 = t4 - 1:int\n(BinOp)" [color=orange];
+	"t1 = t0 + 1:int\n(BinOp)" -> "1:int\n(Const)" [color=orange];
+	"t2 = \"error: \":string + \"unreachable code\":string\n(BinOp)" -> "t3 = fmt.Printf(t2, nil:[]interface{}...)\n(Call)" [color=red];
+	"t2 = \"error: \":string + \"unreachable code\":string\n(BinOp)" -> "\"error: \":string\n(Const)" [color=orange];
+	"t2 = \"error: \":string + \"unreachable code\":string\n(BinOp)" -> "\"unreachable code\":string\n(Const)" [color=orange];
+	"t3 = fmt.Printf(t2, nil:[]interface{}...)\n(Call)" -> "Printf\n(Function)" [color=orange];
+	"t3 = fmt.Printf(t2, nil:[]interface{}...)\n(Call)" -> "t2 = \"error: \":string + \"unreachable code\":string\n(BinOp)" [color=orange];
+	"t3 = fmt.Printf(t2, nil:[]interface{}...)\n(Call)" -> "nil:[]interface{}\n(Const)" [color=orange];
+	"t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" -> "t6 = t4 < t5\n(BinOp)" [color=red];
+	"t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" -> "t0 = t4 - 1:int\n(BinOp)" [color=red];
+	"t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" -> "0:int\n(Const)" [color=orange];
+	"t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" -> "t1 = t0 + 1:int\n(BinOp)" [color=orange];
+	"t5 = 1:int * 2:int\n(BinOp)" -> "t6 = t4 < t5\n(BinOp)" [color=red];
+	"t5 = 1:int * 2:int\n(BinOp)" -> "1:int\n(Const)" [color=orange];
+	"t5 = 1:int * 2:int\n(BinOp)" -> "2:int\n(Const)" [color=orange];
+	"t6 = t4 < t5\n(BinOp)" -> "if t6 goto 1 else 2\n(If)" [color=red];
+	"t6 = t4 < t5\n(BinOp)" -> "t4 = phi [0: 0:int, 1: t1] #i\n(Phi)" [color=orange];
+	"t6 = t4 < t5\n(BinOp)" -> "t5 = 1:int * 2:int\n(BinOp)" [color=orange];
+	"if t6 goto 1 else 2\n(If)" -> "t6 = t4 < t5\n(BinOp)" [color=orange];
+}
diff --git a/internal/pkg/debug/render/testdata/TestDisconnected.ssa b/internal/pkg/debug/render/testdata/TestDisconnected.ssa
new file mode 100644
index 0000000..836c118
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestDisconnected.ssa
@@ -0,0 +1,16 @@
+func TestDisconnected()
+0: entry
+	0(*ssa.Jump           ): jump 3
+1: for.loop
+	0(*ssa.Phi            ): t4 = phi [0: 0:int, 1: t1] #i
+	1(*ssa.BinOp          ): t5 = 1:int * 2:int
+	2(*ssa.BinOp          ): t6 = t4 < t5
+	3(*ssa.If             ): if t6 goto 1 else 2
+2: for.body
+	0(*ssa.BinOp          ): t0 = t4 - 1:int
+	1(*ssa.BinOp          ): t1 = t0 + 1:int
+	2(*ssa.Jump           ): jump 3
+3: for.done
+	0(*ssa.BinOp          ): t2 = "error: ":string + "unreachable code":string
+	1(*ssa.Call           ): t3 = fmt.Printf(t2, nil:[]interface{}...)
+	2(*ssa.Return         ): return
diff --git a/internal/pkg/debug/render/testdata/TestMultiBlock.dot b/internal/pkg/debug/render/testdata/TestMultiBlock.dot
new file mode 100644
index 0000000..cafa27a
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestMultiBlock.dot
@@ -0,0 +1,130 @@
+digraph {
+	subgraph cluster_0 {
+		color=black;
+		label="entry";
+		"t0 = local image.Point (p)\n(Alloc)" [shape=rectangle];
+		"t1 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"t2 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"*t1 = 1:int\n(Store)" [shape=diamond];
+		"*t2 = 2:int\n(Store)" [shape=diamond];
+		"t3 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"t4 = *t3\n(UnOp)" [shape=rectangle];
+		"t5 = t4 > 0:int\n(BinOp)" [shape=rectangle];
+		"if t5 goto 1 else 3\n(If)" [shape=diamond];
+	}
+	subgraph cluster_1 {
+		color=black;
+		label="if.then";
+		"t6 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"t7 = *t6\n(UnOp)" [shape=rectangle];
+		"t8 = t7 > 0:int\n(BinOp)" [shape=rectangle];
+		"if t8 goto 4 else 2\n(If)" [shape=diamond];
+	}
+	subgraph cluster_2 {
+		color=black;
+		label="if.done";
+		"return\n(Return)" [shape=diamond];
+	}
+	subgraph cluster_3 {
+		color=black;
+		label="if.else";
+		"t9 = new [1]interface{} (varargs)\n(Alloc)" [shape=rectangle];
+		"t10 = &t9[0:int]\n(IndexAddr)" [shape=rectangle];
+		"t11 = make interface{} <- string (\"somewhere\":string)\n(MakeInterface)" [shape=rectangle];
+		"*t10 = t11\n(Store)" [shape=diamond];
+		"t12 = slice t9[:]\n(Slice)" [shape=rectangle];
+		"t13 = fmt.Println(t12...)\n(Call)" [shape=rectangle];
+		"jump 2\n(Jump)" [shape=diamond];
+	}
+	subgraph cluster_4 {
+		color=black;
+		label="if.then";
+		"t14 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"t15 = *t14\n(UnOp)" [shape=rectangle];
+		"t16 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"t17 = *t16\n(UnOp)" [shape=rectangle];
+		"t18 = new [2]interface{} (varargs)\n(Alloc)" [shape=rectangle];
+		"t19 = &t18[0:int]\n(IndexAddr)" [shape=rectangle];
+		"t20 = make interface{} <- int (t15)\n(MakeInterface)" [shape=rectangle];
+		"*t19 = t20\n(Store)" [shape=diamond];
+		"t21 = &t18[1:int]\n(IndexAddr)" [shape=rectangle];
+		"t22 = make interface{} <- int (t17)\n(MakeInterface)" [shape=rectangle];
+		"*t21 = t22\n(Store)" [shape=diamond];
+		"t23 = slice t18[:]\n(Slice)" [shape=rectangle];
+		"t24 = fmt.Printf(\"in top right quad...\":string, t23...)\n(Call)" [shape=rectangle];
+		"jump 2\n(Jump)" [shape=diamond];
+	}
+	"t0 = local image.Point (p)\n(Alloc)" -> "t1 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t2 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t3 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t6 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t14 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t16 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t1 = &t0.X [#0]\n(FieldAddr)" -> "*t1 = 1:int\n(Store)" [color=red];
+	"t1 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t2 = &t0.Y [#1]\n(FieldAddr)" -> "*t2 = 2:int\n(Store)" [color=red];
+	"t2 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"*t1 = 1:int\n(Store)" -> "t1 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"*t1 = 1:int\n(Store)" -> "1:int\n(Const)" [color=orange];
+	"*t2 = 2:int\n(Store)" -> "t2 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"*t2 = 2:int\n(Store)" -> "2:int\n(Const)" [color=orange];
+	"t3 = &t0.X [#0]\n(FieldAddr)" -> "t4 = *t3\n(UnOp)" [color=red];
+	"t3 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t4 = *t3\n(UnOp)" -> "t5 = t4 > 0:int\n(BinOp)" [color=red];
+	"t4 = *t3\n(UnOp)" -> "t3 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"t5 = t4 > 0:int\n(BinOp)" -> "if t5 goto 1 else 3\n(If)" [color=red];
+	"t5 = t4 > 0:int\n(BinOp)" -> "t4 = *t3\n(UnOp)" [color=orange];
+	"t5 = t4 > 0:int\n(BinOp)" -> "0:int\n(Const)" [color=orange];
+	"if t5 goto 1 else 3\n(If)" -> "t5 = t4 > 0:int\n(BinOp)" [color=orange];
+	"t6 = &t0.Y [#1]\n(FieldAddr)" -> "t7 = *t6\n(UnOp)" [color=red];
+	"t6 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t7 = *t6\n(UnOp)" -> "t8 = t7 > 0:int\n(BinOp)" [color=red];
+	"t7 = *t6\n(UnOp)" -> "t6 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"t8 = t7 > 0:int\n(BinOp)" -> "if t8 goto 4 else 2\n(If)" [color=red];
+	"t8 = t7 > 0:int\n(BinOp)" -> "t7 = *t6\n(UnOp)" [color=orange];
+	"t8 = t7 > 0:int\n(BinOp)" -> "0:int\n(Const)" [color=orange];
+	"if t8 goto 4 else 2\n(If)" -> "t8 = t7 > 0:int\n(BinOp)" [color=orange];
+	"t9 = new [1]interface{} (varargs)\n(Alloc)" -> "t10 = &t9[0:int]\n(IndexAddr)" [color=red];
+	"t9 = new [1]interface{} (varargs)\n(Alloc)" -> "t12 = slice t9[:]\n(Slice)" [color=red];
+	"t10 = &t9[0:int]\n(IndexAddr)" -> "*t10 = t11\n(Store)" [color=red];
+	"t10 = &t9[0:int]\n(IndexAddr)" -> "t9 = new [1]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t10 = &t9[0:int]\n(IndexAddr)" -> "0:int\n(Const)" [color=orange];
+	"t11 = make interface{} <- string (\"somewhere\":string)\n(MakeInterface)" -> "*t10 = t11\n(Store)" [color=red];
+	"t11 = make interface{} <- string (\"somewhere\":string)\n(MakeInterface)" -> "\"somewhere\":string\n(Const)" [color=orange];
+	"*t10 = t11\n(Store)" -> "t10 = &t9[0:int]\n(IndexAddr)" [color=orange];
+	"*t10 = t11\n(Store)" -> "t11 = make interface{} <- string (\"somewhere\":string)\n(MakeInterface)" [color=orange];
+	"t12 = slice t9[:]\n(Slice)" -> "t13 = fmt.Println(t12...)\n(Call)" [color=red];
+	"t12 = slice t9[:]\n(Slice)" -> "t9 = new [1]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t13 = fmt.Println(t12...)\n(Call)" -> "Println\n(Function)" [color=orange];
+	"t13 = fmt.Println(t12...)\n(Call)" -> "t12 = slice t9[:]\n(Slice)" [color=orange];
+	"t14 = &t0.X [#0]\n(FieldAddr)" -> "t15 = *t14\n(UnOp)" [color=red];
+	"t14 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t15 = *t14\n(UnOp)" -> "t20 = make interface{} <- int (t15)\n(MakeInterface)" [color=red];
+	"t15 = *t14\n(UnOp)" -> "t14 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"t16 = &t0.Y [#1]\n(FieldAddr)" -> "t17 = *t16\n(UnOp)" [color=red];
+	"t16 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t17 = *t16\n(UnOp)" -> "t22 = make interface{} <- int (t17)\n(MakeInterface)" [color=red];
+	"t17 = *t16\n(UnOp)" -> "t16 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"t18 = new [2]interface{} (varargs)\n(Alloc)" -> "t19 = &t18[0:int]\n(IndexAddr)" [color=red];
+	"t18 = new [2]interface{} (varargs)\n(Alloc)" -> "t21 = &t18[1:int]\n(IndexAddr)" [color=red];
+	"t18 = new [2]interface{} (varargs)\n(Alloc)" -> "t23 = slice t18[:]\n(Slice)" [color=red];
+	"t19 = &t18[0:int]\n(IndexAddr)" -> "*t19 = t20\n(Store)" [color=red];
+	"t19 = &t18[0:int]\n(IndexAddr)" -> "t18 = new [2]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t19 = &t18[0:int]\n(IndexAddr)" -> "0:int\n(Const)" [color=orange];
+	"t20 = make interface{} <- int (t15)\n(MakeInterface)" -> "*t19 = t20\n(Store)" [color=red];
+	"t20 = make interface{} <- int (t15)\n(MakeInterface)" -> "t15 = *t14\n(UnOp)" [color=orange];
+	"*t19 = t20\n(Store)" -> "t19 = &t18[0:int]\n(IndexAddr)" [color=orange];
+	"*t19 = t20\n(Store)" -> "t20 = make interface{} <- int (t15)\n(MakeInterface)" [color=orange];
+	"t21 = &t18[1:int]\n(IndexAddr)" -> "*t21 = t22\n(Store)" [color=red];
+	"t21 = &t18[1:int]\n(IndexAddr)" -> "t18 = new [2]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t21 = &t18[1:int]\n(IndexAddr)" -> "1:int\n(Const)" [color=orange];
+	"t22 = make interface{} <- int (t17)\n(MakeInterface)" -> "*t21 = t22\n(Store)" [color=red];
+	"t22 = make interface{} <- int (t17)\n(MakeInterface)" -> "t17 = *t16\n(UnOp)" [color=orange];
+	"*t21 = t22\n(Store)" -> "t21 = &t18[1:int]\n(IndexAddr)" [color=orange];
+	"*t21 = t22\n(Store)" -> "t22 = make interface{} <- int (t17)\n(MakeInterface)" [color=orange];
+	"t23 = slice t18[:]\n(Slice)" -> "t24 = fmt.Printf(\"in top right quad...\":string, t23...)\n(Call)" [color=red];
+	"t23 = slice t18[:]\n(Slice)" -> "t18 = new [2]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t24 = fmt.Printf(\"in top right quad...\":string, t23...)\n(Call)" -> "Printf\n(Function)" [color=orange];
+	"t24 = fmt.Printf(\"in top right quad...\":string, t23...)\n(Call)" -> "\"in top right quad...\":string\n(Const)" [color=orange];
+	"t24 = fmt.Printf(\"in top right quad...\":string, t23...)\n(Call)" -> "t23 = slice t18[:]\n(Slice)" [color=orange];
+}
diff --git a/internal/pkg/debug/render/testdata/TestParams.dot b/internal/pkg/debug/render/testdata/TestParams.dot
new file mode 100644
index 0000000..dff7f4c
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestParams.dot
@@ -0,0 +1,48 @@
+digraph {
+	subgraph cluster_0 {
+		color=black;
+		label="entry";
+		"t0 = new [3]interface{} (varargs)\n(Alloc)" [shape=rectangle];
+		"t1 = &t0[0:int]\n(IndexAddr)" [shape=rectangle];
+		"t2 = make interface{} <- int (a)\n(MakeInterface)" [shape=rectangle];
+		"*t1 = t2\n(Store)" [shape=diamond];
+		"t3 = &t0[1:int]\n(IndexAddr)" [shape=rectangle];
+		"t4 = make interface{} <- int (b)\n(MakeInterface)" [shape=rectangle];
+		"*t3 = t4\n(Store)" [shape=diamond];
+		"t5 = &t0[2:int]\n(IndexAddr)" [shape=rectangle];
+		"t6 = make interface{} <- string (c)\n(MakeInterface)" [shape=rectangle];
+		"*t5 = t6\n(Store)" [shape=diamond];
+		"t7 = slice t0[:]\n(Slice)" [shape=rectangle];
+		"t8 = fmt.Println(t7...)\n(Call)" [shape=rectangle];
+		"return\n(Return)" [shape=diamond];
+	}
+	"t0 = new [3]interface{} (varargs)\n(Alloc)" -> "t1 = &t0[0:int]\n(IndexAddr)" [color=red];
+	"t0 = new [3]interface{} (varargs)\n(Alloc)" -> "t3 = &t0[1:int]\n(IndexAddr)" [color=red];
+	"t0 = new [3]interface{} (varargs)\n(Alloc)" -> "t5 = &t0[2:int]\n(IndexAddr)" [color=red];
+	"t0 = new [3]interface{} (varargs)\n(Alloc)" -> "t7 = slice t0[:]\n(Slice)" [color=red];
+	"t1 = &t0[0:int]\n(IndexAddr)" -> "*t1 = t2\n(Store)" [color=red];
+	"t1 = &t0[0:int]\n(IndexAddr)" -> "t0 = new [3]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t1 = &t0[0:int]\n(IndexAddr)" -> "0:int\n(Const)" [color=orange];
+	"t2 = make interface{} <- int (a)\n(MakeInterface)" -> "*t1 = t2\n(Store)" [color=red];
+	"t2 = make interface{} <- int (a)\n(MakeInterface)" -> "a\n(Parameter)" [color=orange];
+	"*t1 = t2\n(Store)" -> "t1 = &t0[0:int]\n(IndexAddr)" [color=orange];
+	"*t1 = t2\n(Store)" -> "t2 = make interface{} <- int (a)\n(MakeInterface)" [color=orange];
+	"t3 = &t0[1:int]\n(IndexAddr)" -> "*t3 = t4\n(Store)" [color=red];
+	"t3 = &t0[1:int]\n(IndexAddr)" -> "t0 = new [3]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t3 = &t0[1:int]\n(IndexAddr)" -> "1:int\n(Const)" [color=orange];
+	"t4 = make interface{} <- int (b)\n(MakeInterface)" -> "*t3 = t4\n(Store)" [color=red];
+	"t4 = make interface{} <- int (b)\n(MakeInterface)" -> "b\n(Parameter)" [color=orange];
+	"*t3 = t4\n(Store)" -> "t3 = &t0[1:int]\n(IndexAddr)" [color=orange];
+	"*t3 = t4\n(Store)" -> "t4 = make interface{} <- int (b)\n(MakeInterface)" [color=orange];
+	"t5 = &t0[2:int]\n(IndexAddr)" -> "*t5 = t6\n(Store)" [color=red];
+	"t5 = &t0[2:int]\n(IndexAddr)" -> "t0 = new [3]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t5 = &t0[2:int]\n(IndexAddr)" -> "2:int\n(Const)" [color=orange];
+	"t6 = make interface{} <- string (c)\n(MakeInterface)" -> "*t5 = t6\n(Store)" [color=red];
+	"t6 = make interface{} <- string (c)\n(MakeInterface)" -> "c\n(Parameter)" [color=orange];
+	"*t5 = t6\n(Store)" -> "t5 = &t0[2:int]\n(IndexAddr)" [color=orange];
+	"*t5 = t6\n(Store)" -> "t6 = make interface{} <- string (c)\n(MakeInterface)" [color=orange];
+	"t7 = slice t0[:]\n(Slice)" -> "t8 = fmt.Println(t7...)\n(Call)" [color=red];
+	"t7 = slice t0[:]\n(Slice)" -> "t0 = new [3]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t8 = fmt.Println(t7...)\n(Call)" -> "Println\n(Function)" [color=orange];
+	"t8 = fmt.Println(t7...)\n(Call)" -> "t7 = slice t0[:]\n(Slice)" [color=orange];
+}
diff --git a/internal/pkg/debug/render/testdata/TestSingleBlock.dot b/internal/pkg/debug/render/testdata/TestSingleBlock.dot
new file mode 100644
index 0000000..7afeba1
--- /dev/null
+++ b/internal/pkg/debug/render/testdata/TestSingleBlock.dot
@@ -0,0 +1,73 @@
+digraph {
+	subgraph cluster_0 {
+		color=black;
+		label="entry";
+		"t0 = local image.Point (p)\n(Alloc)" [shape=rectangle];
+		"t1 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"t2 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"*t1 = 1:int\n(Store)" [shape=diamond];
+		"*t2 = 2:int\n(Store)" [shape=diamond];
+		"t3 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"*t3 = 3:int\n(Store)" [shape=diamond];
+		"t4 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"*t4 = 4:int\n(Store)" [shape=diamond];
+		"t5 = &t0.X [#0]\n(FieldAddr)" [shape=rectangle];
+		"t6 = *t5\n(UnOp)" [shape=rectangle];
+		"t7 = &t0.Y [#1]\n(FieldAddr)" [shape=rectangle];
+		"t8 = *t7\n(UnOp)" [shape=rectangle];
+		"t9 = t6 + t8\n(BinOp)" [shape=rectangle];
+		"t10 = new [1]interface{} (varargs)\n(Alloc)" [shape=rectangle];
+		"t11 = &t10[0:int]\n(IndexAddr)" [shape=rectangle];
+		"t12 = make interface{} <- int (t9)\n(MakeInterface)" [shape=rectangle];
+		"*t11 = t12\n(Store)" [shape=diamond];
+		"t13 = slice t10[:]\n(Slice)" [shape=rectangle];
+		"t14 = fmt.Println(t13...)\n(Call)" [shape=rectangle];
+		"return\n(Return)" [shape=diamond];
+	}
+	"t0 = local image.Point (p)\n(Alloc)" -> "t1 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t2 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t3 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t4 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t5 = &t0.X [#0]\n(FieldAddr)" [color=red];
+	"t0 = local image.Point (p)\n(Alloc)" -> "t7 = &t0.Y [#1]\n(FieldAddr)" [color=red];
+	"t1 = &t0.X [#0]\n(FieldAddr)" -> "*t1 = 1:int\n(Store)" [color=red];
+	"t1 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t2 = &t0.Y [#1]\n(FieldAddr)" -> "*t2 = 2:int\n(Store)" [color=red];
+	"t2 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"*t1 = 1:int\n(Store)" -> "t1 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"*t1 = 1:int\n(Store)" -> "1:int\n(Const)" [color=orange];
+	"*t2 = 2:int\n(Store)" -> "t2 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"*t2 = 2:int\n(Store)" -> "2:int\n(Const)" [color=orange];
+	"t3 = &t0.X [#0]\n(FieldAddr)" -> "*t3 = 3:int\n(Store)" [color=red];
+	"t3 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"*t3 = 3:int\n(Store)" -> "t3 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"*t3 = 3:int\n(Store)" -> "3:int\n(Const)" [color=orange];
+	"t4 = &t0.Y [#1]\n(FieldAddr)" -> "*t4 = 4:int\n(Store)" [color=red];
+	"t4 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"*t4 = 4:int\n(Store)" -> "t4 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"*t4 = 4:int\n(Store)" -> "4:int\n(Const)" [color=orange];
+	"t5 = &t0.X [#0]\n(FieldAddr)" -> "t6 = *t5\n(UnOp)" [color=red];
+	"t5 = &t0.X [#0]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t6 = *t5\n(UnOp)" -> "t9 = t6 + t8\n(BinOp)" [color=red];
+	"t6 = *t5\n(UnOp)" -> "t5 = &t0.X [#0]\n(FieldAddr)" [color=orange];
+	"t7 = &t0.Y [#1]\n(FieldAddr)" -> "t8 = *t7\n(UnOp)" [color=red];
+	"t7 = &t0.Y [#1]\n(FieldAddr)" -> "t0 = local image.Point (p)\n(Alloc)" [color=orange];
+	"t8 = *t7\n(UnOp)" -> "t9 = t6 + t8\n(BinOp)" [color=red];
+	"t8 = *t7\n(UnOp)" -> "t7 = &t0.Y [#1]\n(FieldAddr)" [color=orange];
+	"t9 = t6 + t8\n(BinOp)" -> "t12 = make interface{} <- int (t9)\n(MakeInterface)" [color=red];
+	"t9 = t6 + t8\n(BinOp)" -> "t6 = *t5\n(UnOp)" [color=orange];
+	"t9 = t6 + t8\n(BinOp)" -> "t8 = *t7\n(UnOp)" [color=orange];
+	"t10 = new [1]interface{} (varargs)\n(Alloc)" -> "t11 = &t10[0:int]\n(IndexAddr)" [color=red];
+	"t10 = new [1]interface{} (varargs)\n(Alloc)" -> "t13 = slice t10[:]\n(Slice)" [color=red];
+	"t11 = &t10[0:int]\n(IndexAddr)" -> "*t11 = t12\n(Store)" [color=red];
+	"t11 = &t10[0:int]\n(IndexAddr)" -> "t10 = new [1]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t11 = &t10[0:int]\n(IndexAddr)" -> "0:int\n(Const)" [color=orange];
+	"t12 = make interface{} <- int (t9)\n(MakeInterface)" -> "*t11 = t12\n(Store)" [color=red];
+	"t12 = make interface{} <- int (t9)\n(MakeInterface)" -> "t9 = t6 + t8\n(BinOp)" [color=orange];
+	"*t11 = t12\n(Store)" -> "t11 = &t10[0:int]\n(IndexAddr)" [color=orange];
+	"*t11 = t12\n(Store)" -> "t12 = make interface{} <- int (t9)\n(MakeInterface)" [color=orange];
+	"t13 = slice t10[:]\n(Slice)" -> "t14 = fmt.Println(t13...)\n(Call)" [color=red];
+	"t13 = slice t10[:]\n(Slice)" -> "t10 = new [1]interface{} (varargs)\n(Alloc)" [color=orange];
+	"t14 = fmt.Println(t13...)\n(Call)" -> "Println\n(Function)" [color=orange];
+	"t14 = fmt.Println(t13...)\n(Call)" -> "t13 = slice t10[:]\n(Slice)" [color=orange];
+}
diff --git a/internal/pkg/debug/render/testdata/tests.go b/internal/pkg/debug/render/testdata/tests.go
index 64dc3a2..bf9941c 100644
--- a/internal/pkg/debug/render/testdata/tests.go
+++ b/internal/pkg/debug/render/testdata/tests.go
@@ -48,3 +48,14 @@ func TestClosure() {
 	}
 	f(x)
 }
+
+func TestDisconnected() {
+	x, y := 1, 2
+	for i := 0; i < x*y; i++ {
+		i--
+	}
+
+	prefix := "error: "
+	message := "unreachable code"
+	fmt.Printf(prefix + message)
+}
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/binop/tests.go b/internal/pkg/levee/testdata/src/example.com/tests/binop/tests.go
index 44b4020..5da7316 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/binop/tests.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/binop/tests.go
@@ -24,8 +24,7 @@ func TestConcatenatingTaintedAndNonTaintedStrings(prefix string) {
 	s := core.Source{Data: "password1234"}
 	message := fmt.Sprintf("source: %v", s)
 	fullMessage := prefix + message
-	// TODO: no report should be produced for "prefix"
-	core.Sink(prefix)      // want "a source has reached a sink"
+	core.Sink(prefix)
 	core.Sink(message)     // want "a source has reached a sink"
 	core.Sink(fullMessage) // want "a source has reached a sink"
 }
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/collections/arrays.go b/internal/pkg/levee/testdata/src/example.com/tests/collections/arrays.go
index eb0fbf0..3edf8d0 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/collections/arrays.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/collections/arrays.go
@@ -45,7 +45,6 @@ func TestRangeOverArray() {
 	sources := [1]core.Source{core.Source{Data: "password1234"}}
 	for i, s := range sources {
 		core.Sink(s) // want "a source has reached a sink"
-		// TODO want no diagnostic reported for string value
-		core.Sink(i) // want "a source has reached a sink"
+		core.Sink(i)
 	}
 }
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/collections/maps.go b/internal/pkg/levee/testdata/src/example.com/tests/collections/maps.go
index b3a2de4..e251a52 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/collections/maps.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/collections/maps.go
@@ -61,22 +61,19 @@ func TestDeletingFromTaintedMapDoesNotTaintTheKey(key string, sources map[string
 
 func TestMapUpdateWithTaintedValueDoesNotTaintTheKey(key string, value core.Source, sources map[string]core.Source) {
 	sources[key] = value
-	// TODO: no report should be produced here
-	core.Sink(key) // want "a source has reached a sink"
+	core.Sink(key)
 }
 
 func TestMapUpdateWithTaintedKeyDoesNotTaintTheValue(key core.Source, value string, sources map[core.Source]string) {
 	sources[key] = value
-	// TODO: no report should be produced here
-	core.Sink(value) // want "a source has reached a sink"
+	core.Sink(value)
 }
 
 func TestRangeOverMapWithSourceAsValue() {
 	m := map[string]core.Source{"secret": core.Source{Data: "password1234"}}
 	for k, s := range m {
 		core.Sink(s) // want "a source has reached a sink"
-		// TODO want no diagnostic reported for string key
-		core.Sink(k) // want "a source has reached a sink"
+		core.Sink(k)
 	}
 }
 
@@ -84,7 +81,6 @@ func TestRangeOverMapWithSourceAsKey() {
 	m := map[core.Source]string{core.Source{Data: "password1234"}: "don't sink me"}
 	for src, str := range m {
 		core.Sink(src) // want "a source has reached a sink"
-		// TODO want no diagnostic reported for string value
-		core.Sink(str) // want "a source has reached a sink"
+		core.Sink(str)
 	}
 }
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/collections/slices.go b/internal/pkg/levee/testdata/src/example.com/tests/collections/slices.go
index ec63ebf..da2f641 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/collections/slices.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/collections/slices.go
@@ -31,15 +31,13 @@ func TestRangeOverSlice() {
 	sources := []core.Source{core.Source{Data: "password1234"}}
 	for i, s := range sources {
 		core.Sink(s) // want "a source has reached a sink"
-		// TODO want no diagnostic reported for string value
-		core.Sink(i) // want "a source has reached a sink"
+		core.Sink(i)
 	}
 }
 
 func TestRangeOverInterfaceSlice() {
 	for i, s := range []interface{}{core.Source{Data: "password1235"}} {
 		core.Sink(s) // want "a source has reached a sink"
-		// TODO want no diagnostic reported for string value
-		core.Sink(i) // want "a source has reached a sink"
+		core.Sink(i)
 	}
 }
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/phi/tests.go b/internal/pkg/levee/testdata/src/example.com/tests/phi/tests.go
index bf7a38f..d065af6 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/phi/tests.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/phi/tests.go
@@ -25,10 +25,8 @@ func TestPhiNodeDoesntPropagateTaintToOperands(i *core.Innocuous) {
 		if true {
 			ss = ii
 		}
-		// TODO: no report should be produced for ii
-		core.Sink(ii) // want "a source has reached a sink"
-		// TODO: no report should be produced for i
-		core.Sink(i)  // want "a source has reached a sink"
+		core.Sink(ii)
+		core.Sink(i)
 		core.Sink(ss) // want "a source has reached a sink"
 	}
 }
diff --git a/internal/pkg/levee/testdata/src/example.com/tests/typeassert/tests.go b/internal/pkg/levee/testdata/src/example.com/tests/typeassert/tests.go
index 5c3f2ce..db62d4c 100644
--- a/internal/pkg/levee/testdata/src/example.com/tests/typeassert/tests.go
+++ b/internal/pkg/levee/testdata/src/example.com/tests/typeassert/tests.go
@@ -36,5 +36,5 @@ func TestSourcePointerAssertedFromParameterEface(e interface{}) {
 func TestSourcePointerAssertedFromParameterEfaceCommaOk(e interface{}) {
 	s, ok := e.(*core.Source)
 	core.Sink(s) // want "a source has reached a sink"
-	_ = ok
+	core.Sink(ok)
 }