Repository: openllb/hlb
Pull request #290 (2022-02-08 18:47:15)

Refactor import modules into codegen using lazy async
- Previously, all `ImportDecl` in the AST are crawled and resolved before codegen even begins, even if they are not been used, and also disallows imports in imports since it was a one-pass.
- Imports are now emitted dynamically during codegen, making them (1) lazy, (2) recursive, allowing you to use imports within imports.
- Example, running `hlb run -t lint` no longer pulls in `image("openllb/go.hlb")` because its code path doesn't depend on the import even if the module does.

## Implementation notes
- Imports are evaluated before call{stmt,expr} arguments are evaluated because we need the type information for its arguments. The signatures are now embedded in the `(parser.Call{Stmt,Expr}).Signature` during `checker` for easy access.
- Codegen no longer needs to guess the arg types for `(*CodeGen).Evaluate` since it has access to signatures.
- Refactor `module.Resolver` to `codegen.Resolver`
- Refactor `module.Resolved` to `parser.Directory`
- Refactor `cmd/hlb/command/context.go` client to `client.go` for re-use in `langserver`
- Once import is resolved, changed `obj.Data = imod.Scope` to `obj.Data = imod`, since we need access to both scope and directory interface.
- `Callable` and `BuiltinDecl` were refactored around due to golang import cycles
- Import related tests moved from `checker_test` to `codegen_test`
- Linter recursive removed because its already recursive in codegen's dynamic imports
- Move lint tests to `linter_test`, updated `checker_test` and `codegen_test` so they don't expect lint transforms


Patch:
diff --git a/builtin/builtin.go b/builtin/builtin.go
index 673d2bb..bcaaee0 100644
--- a/builtin/builtin.go
+++ b/builtin/builtin.go
@@ -2,11 +2,8 @@ package builtin
 
 import (
 	"context"
-	"fmt"
-	"reflect"
 	"strings"
 
-	"github.com/openllb/hlb/codegen"
 	"github.com/openllb/hlb/diagnostic"
 	"github.com/openllb/hlb/parser"
 	"github.com/openllb/hlb/pkg/filebuffer"
@@ -17,149 +14,6 @@ var (
 	Module *parser.Module
 
 	FileBuffer *filebuffer.FileBuffer
-
-	Callables = map[parser.Kind]map[string]parser.Callable{
-		parser.Filesystem: {
-			"scratch":               codegen.Scratch{},
-			"image":                 codegen.Image{},
-			"http":                  codegen.HTTP{},
-			"git":                   codegen.Git{},
-			"local":                 codegen.Local{},
-			"frontend":              codegen.Frontend{},
-			"run":                   codegen.Run{},
-			"env":                   codegen.Env{},
-			"dir":                   codegen.Dir{},
-			"user":                  codegen.User{},
-			"mkdir":                 codegen.Mkdir{},
-			"mkfile":                codegen.Mkfile{},
-			"rm":                    codegen.Rm{},
-			"copy":                  codegen.Copy{},
-			"merge":                 codegen.Merge{},
-			"diff":                  codegen.Diff{},
-			"entrypoint":            codegen.Entrypoint{},
-			"cmd":                   codegen.Cmd{},
-			"label":                 codegen.Label{},
-			"expose":                codegen.Expose{},
-			"volumes":               codegen.Volumes{},
-			"stopSignal":            codegen.StopSignal{},
-			"dockerPush":            codegen.DockerPush{},
-			"dockerLoad":            codegen.DockerLoad{},
-			"download":              codegen.Download{},
-			"downloadTarball":       codegen.DownloadTarball{},
-			"downloadOCITarball":    codegen.DownloadOCITarball{},
-			"downloadDockerTarball": codegen.DownloadDockerTarball{},
-			"breakpoint":            codegen.SetBreakpoint{},
-		},
-		parser.String: {
-			"format":    codegen.Format{},
-			"template":  codegen.Template{},
-			"manifest":  codegen.Manifest{},
-			"localArch": codegen.LocalArch{},
-			"localOs":   codegen.LocalOS{},
-			"localCwd":  codegen.LocalCwd{},
-			"localEnv":  codegen.LocalEnv{},
-			"localRun":  codegen.LocalRun{},
-		},
-		parser.Pipeline: {
-			"stage":    codegen.Stage{},
-			"parallel": codegen.Stage{},
-		},
-		"option::image": {
-			"resolve":  codegen.Resolve{},
-			"platform": codegen.Platform{},
-		},
-		"option::http": {
-			"checksum": codegen.Checksum{},
-			"chmod":    codegen.Chmod{},
-			"filename": codegen.Filename{},
-		},
-		"option::git": {
-			"keepGitDir": codegen.KeepGitDir{},
-		},
-		"option::local": {
-			"includePatterns": codegen.IncludePatterns{},
-			"excludePatterns": codegen.ExcludePatterns{},
-			"followPaths":     codegen.FollowPaths{},
-		},
-		"option::frontend": {
-			"input": codegen.FrontendInput{},
-			"opt":   codegen.FrontendOpt{},
-		},
-		"option::run": {
-			"readonlyRootfs": codegen.ReadonlyRootfs{},
-			"env":            codegen.RunEnv{},
-			"dir":            codegen.RunDir{},
-			"user":           codegen.RunUser{},
-			"ignoreCache":    codegen.IgnoreCache{},
-			"network":        codegen.Network{},
-			"security":       codegen.Security{},
-			"shlex":          codegen.Shlex{},
-			"host":           codegen.Host{},
-			"ssh":            codegen.SSH{},
-			"forward":        codegen.Forward{},
-			"secret":         codegen.Secret{},
-			"mount":          codegen.Mount{},
-			"breakpoint":     codegen.RunBreakpoint{},
-		},
-		"option::ssh": {
-			"target":     codegen.MountTarget{},
-			"uid":        codegen.UID{},
-			"gid":        codegen.GID{},
-			"mode":       codegen.UtilChmod{},
-			"localPaths": codegen.LocalPaths{},
-		},
-		"option::secret": {
-			"uid":             codegen.UID{},
-			"gid":             codegen.GID{},
-			"mode":            codegen.UtilChmod{},
-			"includePatterns": codegen.SecretIncludePatterns{},
-			"excludePatterns": codegen.SecretExcludePatterns{},
-		},
-		"option::mount": {
-			"readonly":   codegen.Readonly{},
-			"tmpfs":      codegen.Tmpfs{},
-			"sourcePath": codegen.SourcePath{},
-			"cache":      codegen.Cache{},
-		},
-		"option::mkdir": {
-			"createParents": codegen.CreateParents{},
-			"chown":         codegen.Chown{},
-			"createdTime":   codegen.CreatedTime{},
-		},
-		"option::mkfile": {
-			"chown":       codegen.Chown{},
-			"createdTime": codegen.CreatedTime{},
-		},
-		"option::rm": {
-			"allowNotFound": codegen.AllowNotFound{},
-			"allowWildcard": codegen.AllowWildcard{},
-		},
-		"option::copy": {
-			"followSymlinks":     codegen.FollowSymlinks{},
-			"contentsOnly":       codegen.ContentsOnly{},
-			"unpack":             codegen.Unpack{},
-			"createDestPath":     codegen.CreateDestPath{},
-			"allowWildcard":      codegen.CopyAllowWildcard{},
-			"allowEmptyWildcard": codegen.AllowEmptyWildcard{},
-			"chown":              codegen.UtilChown{},
-			"chmod":              codegen.UtilChmod{},
-			"createdTime":        codegen.UtilCreatedTime{},
-			"includePatterns":    codegen.CopyIncludePatterns{},
-			"excludePatterns":    codegen.CopyExcludePatterns{},
-		},
-		"option::localRun": {
-			"ignoreError":   codegen.IgnoreError{},
-			"onlyStderr":    codegen.OnlyStderr{},
-			"includeStderr": codegen.IncludeStderr{},
-			"shlex":         codegen.Shlex{},
-		},
-		"option::template": {
-			"stringField": codegen.StringField{},
-		},
-		"option::manifest": {
-			"platform": codegen.Platform{},
-		},
-	}
 )
 
 func init() {
@@ -167,41 +21,6 @@ func init() {
 	if err != nil {
 		panic(err)
 	}
-
-	err = initCallables()
-	if err != nil {
-		panic(err)
-	}
-}
-
-func initCallables() error {
-	protoCall, ok := reflect.TypeOf(codegen.Prototype{}).MethodByName("Call")
-	if !ok {
-		return fmt.Errorf("Prototype has no Call method")
-	}
-
-	// Build prototype signature.
-	for i := 1; i < protoCall.Type.NumIn(); i++ {
-		codegen.PrototypeIn = append(codegen.PrototypeIn, protoCall.Type.In(i))
-	}
-	for i := 0; i < protoCall.Type.NumOut(); i++ {
-		codegen.PrototypeOut = append(codegen.PrototypeOut, protoCall.Type.Out(i))
-	}
-
-	// Type check all the builtin functions.
-	var errs []string
-	for _, byKind := range Callables {
-		for _, callable := range byKind {
-			err := CheckPrototype(callable)
-			if err != nil {
-				errs = append(errs, err.Error())
-			}
-		}
-	}
-	if len(errs) > 0 {
-		return fmt.Errorf(strings.Join(errs, "\n"))
-	}
-	return nil
 }
 
 func initSources() (err error) {
@@ -222,48 +41,3 @@ func Sources() *filebuffer.Sources {
 	sources.Set(FileBuffer.Filename(), FileBuffer)
 	return sources
 }
-
-func CheckPrototype(callable parser.Callable) error {
-	c := reflect.ValueOf(callable).MethodByName("Call")
-
-	var (
-		ins  []reflect.Type
-		outs []reflect.Type
-	)
-	for i := 0; i < c.Type().NumIn(); i++ {
-		ins = append(ins, c.Type().In(i))
-	}
-	for i := 0; i < c.Type().NumOut(); i++ {
-		outs = append(outs, c.Type().Out(i))
-	}
-
-	err := fmt.Errorf(
-		"expected (%s).Call(%s)(%s) to match Call(%s)(%s)",
-		reflect.TypeOf(callable),
-		ins,
-		outs,
-		codegen.PrototypeIn,
-		codegen.PrototypeOut,
-	)
-
-	// Verify callable matches prototype signature.
-	if c.Type().NumIn() < len(codegen.PrototypeIn) || c.Type().NumOut() != len(codegen.PrototypeOut) {
-		return err
-	}
-	for i := 0; i < len(codegen.PrototypeIn); i++ {
-		param := ins[i]
-		if (param.Kind() == reflect.Interface && !param.Implements(codegen.PrototypeIn[i])) ||
-			(param.Kind() != reflect.Interface && param != codegen.PrototypeIn[i]) {
-			return err
-		}
-	}
-	for i := 0; i < len(codegen.PrototypeOut); i++ {
-		param := outs[i]
-		if (param.Kind() == reflect.Interface && !param.Implements(codegen.PrototypeOut[i])) ||
-			(param.Kind() != reflect.Interface && param != codegen.PrototypeOut[i]) {
-			return err
-		}
-	}
-
-	return nil
-}
diff --git a/checker/builtin.go b/checker/builtin.go
index 1e41100..43155c1 100644
--- a/checker/builtin.go
+++ b/checker/builtin.go
@@ -26,15 +26,13 @@ func NewBuiltinScope(builtins builtin.BuiltinLookup) *parser.Scope {
 						Module:         builtin.Module,
 						Name:           fun.Name.String(),
 						FuncDeclByKind: make(map[parser.Kind]*parser.FuncDecl),
-						CallableByKind: make(map[parser.Kind]parser.Callable),
 					},
 				}
 			}
 
 			decl := obj.Node.(*parser.BuiltinDecl)
+			decl.Kinds = append(decl.Kinds, fun.Type.Kind)
 			decl.FuncDeclByKind[fun.Type.Kind] = fun
-			decl.CallableByKind[fun.Type.Kind] = builtin.Callables[fun.Type.Kind][fun.Name.Text]
-
 			scope.Insert(obj)
 		},
 	)
diff --git a/checker/checker.go b/checker/checker.go
index 747df34..c380bf4 100644
--- a/checker/checker.go
+++ b/checker/checker.go
@@ -31,12 +31,12 @@ func Check(mod *parser.Module) error {
 
 // CheckReferences checks for semantic errors for references. Imported modules
 // are assumed to be reachable through the given module.
-func CheckReferences(mod *parser.Module) error {
+func CheckReferences(mod *parser.Module, name string) error {
 	c := &checker{
 		checkRefs: true,
 		dups:      make(map[string][]parser.Node),
 	}
-	return c.CheckReferences(mod)
+	return c.CheckReferences(mod, name)
 }
 
 type checker struct {
@@ -251,12 +251,24 @@ func (c *checker) Check(mod *parser.Module) error {
 	return nil
 }
 
-func (c *checker) CheckReferences(mod *parser.Module) error {
+func (c *checker) CheckReferences(mod *parser.Module, name string) error {
 	// Third pass over the CST.
 	// 3. After imports have resolved, semantic checks of imported identifiers.
 	parser.Match(mod, parser.MatchOpts{},
-		func(block *parser.BlockStmt) {
-			err := c.checkBlock(block)
+		func(id *parser.ImportDecl) {
+			kset := parser.NewKindSet(parser.String, parser.Filesystem)
+			err := c.checkExpr(mod.Scope, kset, id.Expr)
+			if err != nil {
+				c.err(err)
+			}
+		},
+		func(block *parser.BlockStmt, call *parser.CallStmt) {
+			if call.Name.Ident.Text != name {
+				return
+			}
+
+			kset := parser.NewKindSet(block.Kind())
+			err := c.checkCallStmt(block.Scope, kset, call)
 			if err != nil {
 				c.err(err)
 			}
@@ -327,30 +339,55 @@ func (c *checker) checkType(node parser.Node, kset *parser.KindSet, actual parse
 }
 
 func (c *checker) checkCallStmt(scope *parser.Scope, kset *parser.KindSet, call *parser.CallStmt) error {
-	return c.checkCall(scope, kset, call.Name, call.Args, call.WithClause)
+	signature, err := c.checkCall(scope, kset, call.Name, call.Args, call.WithClause)
+	if err != nil {
+		return err
+	}
+	var kinds []parser.Kind
+	for _, field := range signature {
+		kinds = append(kinds, field.Kind())
+	}
+	call.Signature = kinds
+	return nil
 }
 
-func (c *checker) skip(ie *parser.IdentExpr) bool {
-	// Skip references when not checking references and skip non-references
-	// when checking references.
-	return (!c.checkRefs && ie.Reference != nil) || (c.checkRefs && ie.Reference == nil)
+func (c *checker) checkCallExpr(scope *parser.Scope, kset *parser.KindSet, call *parser.CallExpr) error {
+	signature, err := c.checkCall(scope, kset, call.Name, call.Args(), nil)
+	if err != nil {
+		return err
+	}
+	var kinds []parser.Kind
+	for _, field := range signature {
+		kinds = append(kinds, field.Kind())
+	}
+	call.Signature = kinds
+	return nil
 }
 
-func (c *checker) checkCall(scope *parser.Scope, kset *parser.KindSet, ie *parser.IdentExpr, args []*parser.Expr, with *parser.WithClause) error {
-	if c.skip(ie) {
-		return nil
+func (c *checker) skip(ie *parser.IdentExpr) bool {
+	// If not checking references, skip if IdentExpr has a reference.
+	if !c.checkRefs {
+		return ie.Reference != nil
 	}
+	return false
+}
 
+func (c *checker) checkCall(scope *parser.Scope, kset *parser.KindSet, ie *parser.IdentExpr, args []*parser.Expr, with *parser.WithClause) ([]*parser.Field, error) {
 	decl, signature, err := c.checkIdentExpr(scope, kset, ie)
 	if err != nil {
-		return err
+		return nil, err
+	}
+
+	// If not checking references, skip references after checking ie.Name.
+	if c.skip(ie) {
+		return nil, nil
 	}
 
 	// When the signature has a variadic field, construct a temporary signature to
 	// match the calling arguments.
 	params := extendSignatureWithVariadic(signature, args)
 	if len(params) != len(args) {
-		return errdefs.WithNumArgs(
+		return nil, errdefs.WithNumArgs(
 			ie.Ident, len(params), len(args),
 			errdefs.DefinedMaybeImported(scope, ie, decl)...,
 		)
@@ -360,7 +397,7 @@ func (c *checker) checkCall(scope *parser.Scope, kset *parser.KindSet, ie *parse
 		kind := params[i].Type.Kind
 		err := c.checkExpr(scope, parser.NewKindSet(kind), arg)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 
@@ -369,11 +406,11 @@ func (c *checker) checkCall(scope *parser.Scope, kset *parser.KindSet, ie *parse
 		kind := parser.Kind(fmt.Sprintf("%s::%s", parser.Option, ie.Ident))
 		err := c.checkExpr(scope, parser.NewKindSet(kind), with.Expr)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 
-	return nil
+	return params, nil
 }
 
 func (c *checker) checkExpr(scope *parser.Scope, kset *parser.KindSet, expr *parser.Expr) error {
@@ -474,10 +511,6 @@ func (c *checker) checkHeredocFragments(scope *parser.Scope, fragments []*parser
 	return nil
 }
 
-func (c *checker) checkCallExpr(scope *parser.Scope, kset *parser.KindSet, call *parser.CallExpr) error {
-	return c.checkCall(scope, kset, call.Name, call.Args(), nil)
-}
-
 func (c *checker) checkIdentExpr(scope *parser.Scope, kset *parser.KindSet, ie *parser.IdentExpr) (ident *parser.Ident, signature []*parser.Field, err error) {
 	return c.checkIdentExprHelper(scope, kset, ie, ie.Ident)
 }
@@ -500,6 +533,10 @@ func (c *checker) checkIdentExprHelper(scope *parser.Scope, kset *parser.KindSet
 			return
 		}
 	}
+	// If not checking references, skip references after basic lookup and errors.
+	if c.skip(ie) {
+		return
+	}
 
 	switch n := obj.Node.(type) {
 	case *parser.BuiltinDecl:
@@ -522,13 +559,13 @@ func (c *checker) checkIdentExprHelper(scope *parser.Scope, kset *parser.KindSet
 			err = errdefs.WithCallImport(ie.Ident, n.Name)
 			return
 		}
-		importScope, ok := obj.Data.(*parser.Scope)
+		imod, ok := obj.Data.(*parser.Module)
 		if !ok {
 			err = errdefs.WithInternalErrorf(ie.Ident, "import scope is not set")
 			return
 		}
 		opts = append(opts, errdefs.Imported(obj.Ident))
-		return c.checkIdentExprHelper(importScope, kset, ie, ie.Reference.Ident, opts...)
+		return c.checkIdentExprHelper(imod.Scope, kset, ie, ie.Reference.Ident, opts...)
 	case *parser.Field:
 		opts = append(opts, errdefs.Defined(obj.Ident))
 		return obj.Ident, nil, c.checkType(lookup, kset, n.Type.Kind, opts...)
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..2db9821
--- /dev/null
+++ b/client.go
@@ -0,0 +1,66 @@
+package hlb
+
+import (
+	"context"
+	"net"
+
+	"github.com/docker/buildx/store/storeutil"
+	"github.com/docker/buildx/util/imagetools"
+	dockercommand "github.com/docker/cli/cli/command"
+	"github.com/docker/cli/cli/flags"
+	"github.com/moby/buildkit/client"
+	"github.com/openllb/hlb/codegen"
+	"github.com/openllb/hlb/solver"
+)
+
+// Client returns a BuildKit client specified by addr based on BuildKit's
+// connection helpers.
+//
+// If addr is empty, an attempt is made to connect to docker engine's embedded
+// BuildKit which supports a subset of the exporters and special `moby`
+// exporter.
+func Client(ctx context.Context, addr string) (*client.Client, context.Context, error) {
+	// Attempt to connect to a healthy docker engine.
+	dockerCli, auth, err := NewDockerCli(ctx)
+
+	// If addr is empty, connect to BuildKit using connection helpers.
+	if addr != "" {
+		ctx = codegen.WithDockerAPI(ctx, dockerCli.Client(), auth, err, false)
+		cln, err := solver.BuildkitClient(ctx, addr)
+		return cln, ctx, err
+	}
+
+	// Otherwise, connect to docker engine's embedded BuildKit.
+	ctx = codegen.WithDockerAPI(ctx, dockerCli.Client(), auth, err, true)
+	cln, err := client.New(ctx, "", client.WithContextDialer(func(context.Context, string) (net.Conn, error) {
+		return dockerCli.Client().DialHijack(ctx, "/grpc", "h2c", nil)
+	}), client.WithSessionDialer(func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) {
+		return dockerCli.Client().DialHijack(ctx, "/session", proto, meta)
+	}))
+	return cln, ctx, err
+}
+
+func NewDockerCli(ctx context.Context) (dockerCli *dockercommand.DockerCli, auth imagetools.Auth, err error) {
+	dockerCli, err = dockercommand.NewDockerCli()
+	if err != nil {
+		return
+	}
+
+	err = dockerCli.Initialize(flags.NewClientOptions())
+	if err != nil {
+		return
+	}
+
+	_, err = dockerCli.Client().ServerVersion(ctx)
+	if err != nil {
+		return
+	}
+
+	imageopt, err := storeutil.GetImageConfig(dockerCli, nil)
+	if err != nil {
+		return
+	}
+
+	auth = imageopt.Auth
+	return
+}
diff --git a/cmd/hlb/command/context.go b/cmd/hlb/command/context.go
index df6d088..5bcd407 100644
--- a/cmd/hlb/command/context.go
+++ b/cmd/hlb/command/context.go
@@ -2,21 +2,12 @@ package command
 
 import (
 	"context"
-	"net"
 	"os"
 
-	"github.com/docker/buildx/store/storeutil"
-	"github.com/docker/buildx/util/imagetools"
-	dockercommand "github.com/docker/cli/cli/command"
-	"github.com/docker/cli/cli/flags"
 	"github.com/logrusorgru/aurora"
 	isatty "github.com/mattn/go-isatty"
-	"github.com/moby/buildkit/client"
 	"github.com/moby/buildkit/util/appcontext"
-	"github.com/openllb/hlb/codegen"
 	"github.com/openllb/hlb/diagnostic"
-	"github.com/openllb/hlb/solver"
-	cli "github.com/urfave/cli/v2"
 )
 
 func Context() context.Context {
@@ -26,54 +17,3 @@ func Context() context.Context {
 	}
 	return ctx
 }
-
-func Client(c *cli.Context) (*client.Client, context.Context, error) {
-	ctx := Context()
-
-	var (
-		dockerCli *dockercommand.DockerCli
-		auth      imagetools.Auth
-		err       error
-	)
-	for _, f := range []func() error{
-		func() error {
-			dockerCli, err = dockercommand.NewDockerCli()
-			return err
-		},
-		func() error {
-			return dockerCli.Initialize(flags.NewClientOptions())
-		},
-		func() error {
-			_, err = dockerCli.Client().ServerVersion(ctx)
-			return err
-		},
-		func() error {
-			imageopt, err := storeutil.GetImageConfig(dockerCli, nil)
-			if err != nil {
-				return err
-			}
-			auth = imageopt.Auth
-			return nil
-		},
-	} {
-		err = f()
-		if err != nil {
-			break
-		}
-	}
-
-	addr := c.String("addr")
-	if addr != "" {
-		ctx = codegen.WithDockerAPI(ctx, dockerCli.Client(), auth, err, false)
-		cln, err := solver.BuildkitClient(ctx, addr)
-		return cln, ctx, err
-	}
-
-	ctx = codegen.WithDockerAPI(ctx, dockerCli.Client(), auth, err, true)
-	cln, err := client.New(ctx, "", client.WithContextDialer(func(context.Context, string) (net.Conn, error) {
-		return dockerCli.Client().DialHijack(ctx, "/grpc", "h2c", nil)
-	}), client.WithSessionDialer(func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) {
-		return dockerCli.Client().DialHijack(ctx, "/session", proto, meta)
-	}))
-	return cln, ctx, err
-}
diff --git a/cmd/hlb/command/langserver.go b/cmd/hlb/command/langserver.go
index 6ee166b..2a45657 100644
--- a/cmd/hlb/command/langserver.go
+++ b/cmd/hlb/command/langserver.go
@@ -1,10 +1,10 @@
 package command
 
 import (
-	"context"
 	"log"
 	"os"
 
+	"github.com/openllb/hlb"
 	"github.com/openllb/hlb/langserver"
 	cli "github.com/urfave/cli/v2"
 )
@@ -27,9 +27,16 @@ var langserverCommand = &cli.Command{
 		defer f.Close()
 		log.SetOutput(f)
 
-		s := langserver.NewServer()
+		cln, ctx, err := hlb.Client(Context(), c.String("addr"))
+		if err != nil {
+			return err
+		}
+
+		s, err := langserver.NewServer(ctx, cln)
+		if err != nil {
+			return err
+		}
 
-		ctx := context.Background()
-		return s.Listen(ctx, os.Stdin, os.Stdout)
+		return s.Listen(os.Stdin, os.Stdout)
 	},
 }
diff --git a/cmd/hlb/command/lint.go b/cmd/hlb/command/lint.go
index 6083ace..bfc1bc3 100644
--- a/cmd/hlb/command/lint.go
+++ b/cmd/hlb/command/lint.go
@@ -57,7 +57,7 @@ func Lint(ctx context.Context, r io.Reader, info LintInfo) error {
 		return err
 	}
 
-	err = linter.Lint(ctx, mod, linter.WithRecursive())
+	err = linter.Lint(ctx, mod)
 	if err != nil {
 		spans := diagnostic.Spans(err)
 		for _, span := range spans {
diff --git a/cmd/hlb/command/module.go b/cmd/hlb/command/module.go
index 8ed401f..48d9ee9 100644
--- a/cmd/hlb/command/module.go
+++ b/cmd/hlb/command/module.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/moby/buildkit/client"
+	"github.com/openllb/hlb"
 	"github.com/openllb/hlb/builtin"
 	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/codegen"
@@ -45,7 +46,7 @@ var moduleVendorCommand = &cli.Command{
 		},
 	},
 	Action: func(c *cli.Context) error {
-		cln, ctx, err := Client(c)
+		cln, ctx, err := hlb.Client(Context(), c.String("addr"))
 		if err != nil {
 			return err
 		}
@@ -64,7 +65,7 @@ var moduleTidyCommand = &cli.Command{
 	Usage:     "add missing and remove unused modules",
 	ArgsUsage: "<*.hlb>",
 	Action: func(c *cli.Context) error {
-		cln, ctx, err := Client(c)
+		cln, ctx, err := hlb.Client(Context(), c.String("addr"))
 		if err != nil {
 			return err
 		}
@@ -88,7 +89,7 @@ var moduleTreeCommand = &cli.Command{
 		},
 	},
 	Action: func(c *cli.Context) error {
-		cln, ctx, err := Client(c)
+		cln, ctx, err := hlb.Client(Context(), c.String("addr"))
 		if err != nil {
 			return err
 		}
@@ -148,7 +149,7 @@ func Vendor(ctx context.Context, cln *client.Client, info VendorInfo) (err error
 		return err
 	}
 
-	_ = linter.Lint(ctx, mod, linter.WithRecursive())
+	_ = linter.Lint(ctx, mod)
 
 	err = checker.Check(mod)
 	if err != nil {
@@ -218,7 +219,7 @@ func findVendoredModule(errNotExist error, name string) (io.ReadCloser, error) {
 		return nil, fmt.Errorf("ambiguous hlb module, specify more digest characters.")
 	}
 
-	return os.Open(filepath.Join(matchedModules[0], module.ModuleFilename))
+	return os.Open(filepath.Join(matchedModules[0], codegen.ModuleFilename))
 }
 
 type TreeInfo struct {
@@ -259,7 +260,7 @@ func Tree(ctx context.Context, cln *client.Client, info TreeInfo) (err error) {
 		return err
 	}
 
-	_ = linter.Lint(ctx, mod, linter.WithRecursive())
+	_ = linter.Lint(ctx, mod)
 
 	err = checker.Check(mod)
 	if err != nil {
diff --git a/cmd/hlb/command/run.go b/cmd/hlb/command/run.go
index 90eba82..603fbaa 100644
--- a/cmd/hlb/command/run.go
+++ b/cmd/hlb/command/run.go
@@ -76,7 +76,7 @@ var runCommand = &cli.Command{
 		}
 		defer rc.Close()
 
-		cln, ctx, err := Client(c)
+		cln, ctx, err := hlb.Client(Context(), c.String("addr"))
 		if err != nil {
 			return err
 		}
diff --git a/codegen/builtin.go b/codegen/builtin.go
new file mode 100644
index 0000000..20ec892
--- /dev/null
+++ b/codegen/builtin.go
@@ -0,0 +1,236 @@
+package codegen
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/openllb/hlb/parser"
+)
+
+var (
+	Callables = map[parser.Kind]map[string]interface{}{
+		parser.Filesystem: {
+			"scratch":               Scratch{},
+			"image":                 Image{},
+			"http":                  HTTP{},
+			"git":                   Git{},
+			"local":                 Local{},
+			"frontend":              Frontend{},
+			"run":                   Run{},
+			"env":                   Env{},
+			"dir":                   Dir{},
+			"user":                  User{},
+			"mkdir":                 Mkdir{},
+			"mkfile":                Mkfile{},
+			"rm":                    Rm{},
+			"copy":                  Copy{},
+			"merge":                 Merge{},
+			"diff":                  Diff{},
+			"entrypoint":            Entrypoint{},
+			"cmd":                   Cmd{},
+			"label":                 Label{},
+			"expose":                Expose{},
+			"volumes":               Volumes{},
+			"stopSignal":            StopSignal{},
+			"dockerPush":            DockerPush{},
+			"dockerLoad":            DockerLoad{},
+			"download":              Download{},
+			"downloadTarball":       DownloadTarball{},
+			"downloadOCITarball":    DownloadOCITarball{},
+			"downloadDockerTarball": DownloadDockerTarball{},
+			"breakpoint":            SetBreakpoint{},
+		},
+		parser.String: {
+			"format":    Format{},
+			"template":  Template{},
+			"manifest":  Manifest{},
+			"localArch": LocalArch{},
+			"localOs":   LocalOS{},
+			"localCwd":  LocalCwd{},
+			"localEnv":  LocalEnv{},
+			"localRun":  LocalRun{},
+		},
+		parser.Pipeline: {
+			"stage":    Stage{},
+			"parallel": Stage{},
+		},
+		"option::image": {
+			"resolve":  Resolve{},
+			"platform": Platform{},
+		},
+		"option::http": {
+			"checksum": Checksum{},
+			"chmod":    Chmod{},
+			"filename": Filename{},
+		},
+		"option::git": {
+			"keepGitDir": KeepGitDir{},
+		},
+		"option::local": {
+			"includePatterns": IncludePatterns{},
+			"excludePatterns": ExcludePatterns{},
+			"followPaths":     FollowPaths{},
+		},
+		"option::frontend": {
+			"input": FrontendInput{},
+			"opt":   FrontendOpt{},
+		},
+		"option::run": {
+			"readonlyRootfs": ReadonlyRootfs{},
+			"env":            RunEnv{},
+			"dir":            RunDir{},
+			"user":           RunUser{},
+			"ignoreCache":    IgnoreCache{},
+			"network":        Network{},
+			"security":       Security{},
+			"shlex":          Shlex{},
+			"host":           Host{},
+			"ssh":            SSH{},
+			"forward":        Forward{},
+			"secret":         Secret{},
+			"mount":          Mount{},
+			"breakpoint":     RunBreakpoint{},
+		},
+		"option::ssh": {
+			"target":     MountTarget{},
+			"uid":        UID{},
+			"gid":        GID{},
+			"mode":       UtilChmod{},
+			"localPaths": LocalPaths{},
+		},
+		"option::secret": {
+			"uid":             UID{},
+			"gid":             GID{},
+			"mode":            UtilChmod{},
+			"includePatterns": SecretIncludePatterns{},
+			"excludePatterns": SecretExcludePatterns{},
+		},
+		"option::mount": {
+			"readonly":   Readonly{},
+			"tmpfs":      Tmpfs{},
+			"sourcePath": SourcePath{},
+			"cache":      Cache{},
+		},
+		"option::mkdir": {
+			"createParents": CreateParents{},
+			"chown":         Chown{},
+			"createdTime":   CreatedTime{},
+		},
+		"option::mkfile": {
+			"chown":       Chown{},
+			"createdTime": CreatedTime{},
+		},
+		"option::rm": {
+			"allowNotFound": AllowNotFound{},
+			"allowWildcard": AllowWildcard{},
+		},
+		"option::copy": {
+			"followSymlinks":     FollowSymlinks{},
+			"contentsOnly":       ContentsOnly{},
+			"unpack":             Unpack{},
+			"createDestPath":     CreateDestPath{},
+			"allowWildcard":      CopyAllowWildcard{},
+			"allowEmptyWildcard": AllowEmptyWildcard{},
+			"chown":              UtilChown{},
+			"chmod":              UtilChmod{},
+			"createdTime":        UtilCreatedTime{},
+			"includePatterns":    CopyIncludePatterns{},
+			"excludePatterns":    CopyExcludePatterns{},
+		},
+		"option::localRun": {
+			"ignoreError":   IgnoreError{},
+			"onlyStderr":    OnlyStderr{},
+			"includeStderr": IncludeStderr{},
+			"shlex":         Shlex{},
+		},
+		"option::template": {
+			"stringField": StringField{},
+		},
+		"option::manifest": {
+			"platform": Platform{},
+		},
+	}
+)
+
+func init() {
+	err := initCallables()
+	if err != nil {
+		panic(err)
+	}
+}
+
+func initCallables() error {
+	protoCall, ok := reflect.TypeOf(Prototype{}).MethodByName("Call")
+	if !ok {
+		return fmt.Errorf("Prototype has no Call method")
+	}
+
+	// Build prototype signature.
+	for i := 1; i < protoCall.Type.NumIn(); i++ {
+		PrototypeIn = append(PrototypeIn, protoCall.Type.In(i))
+	}
+	for i := 0; i < protoCall.Type.NumOut(); i++ {
+		PrototypeOut = append(PrototypeOut, protoCall.Type.Out(i))
+	}
+
+	// Type check all the builtin functions.
+	var errs []string
+	for _, byKind := range Callables {
+		for _, callable := range byKind {
+			err := CheckPrototype(callable)
+			if err != nil {
+				errs = append(errs, err.Error())
+			}
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf(strings.Join(errs, "\n"))
+	}
+	return nil
+}
+
+func CheckPrototype(callable interface{}) error {
+	c := reflect.ValueOf(callable).MethodByName("Call")
+
+	var (
+		ins  []reflect.Type
+		outs []reflect.Type
+	)
+	for i := 0; i < c.Type().NumIn(); i++ {
+		ins = append(ins, c.Type().In(i))
+	}
+	for i := 0; i < c.Type().NumOut(); i++ {
+		outs = append(outs, c.Type().Out(i))
+	}
+
+	err := fmt.Errorf(
+		"expected (%s).Call(%s)(%s) to match Call(%s)(%s)",
+		reflect.TypeOf(callable),
+		ins,
+		outs,
+		PrototypeIn,
+		PrototypeOut,
+	)
+
+	// Verify callable matches prototype signature.
+	if c.Type().NumIn() < len(PrototypeIn) || c.Type().NumOut() != len(PrototypeOut) {
+		return err
+	}
+	for i := 0; i < len(PrototypeIn); i++ {
+		param := ins[i]
+		if (param.Kind() == reflect.Interface && !param.Implements(PrototypeIn[i])) ||
+			(param.Kind() != reflect.Interface && param != PrototypeIn[i]) {
+			return err
+		}
+	}
+	for i := 0; i < len(PrototypeOut); i++ {
+		param := outs[i]
+		if (param.Kind() == reflect.Interface && !param.Implements(PrototypeOut[i])) ||
+			(param.Kind() != reflect.Interface && param != PrototypeOut[i]) {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/codegen/codegen.go b/codegen/codegen.go
index 718d2dc..d596df4 100644
--- a/codegen/codegen.go
+++ b/codegen/codegen.go
@@ -10,7 +10,9 @@ import (
 
 	"github.com/lithammer/dedent"
 	"github.com/moby/buildkit/client"
+	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/errdefs"
+	"github.com/openllb/hlb/linter"
 	"github.com/openllb/hlb/parser"
 	"github.com/openllb/hlb/solver"
 	"github.com/pkg/errors"
@@ -19,6 +21,7 @@ import (
 type CodeGen struct {
 	Debug          Debugger
 	cln            *client.Client
+	resolver       Resolver
 	extraSolveOpts []solver.SolveOption
 }
 
@@ -38,10 +41,11 @@ func WithExtraSolveOpts(extraOpts []solver.SolveOption) CodeGenOption {
 	}
 }
 
-func New(cln *client.Client, opts ...CodeGenOption) (*CodeGen, error) {
+func New(cln *client.Client, resolver Resolver, opts ...CodeGenOption) (*CodeGen, error) {
 	cg := &CodeGen{
-		Debug: NewNoopDebugger(),
-		cln:   cln,
+		Debug:    NewNoopDebugger(),
+		cln:      cln,
+		resolver: resolver,
 	}
 	for _, opt := range opts {
 		err := opt(cg)
@@ -104,7 +108,17 @@ func (cg *CodeGen) EmitExpr(ctx context.Context, scope *parser.Scope, expr *pars
 	case expr.BasicLit != nil:
 		return cg.EmitBasicLit(ctx, scope, expr.BasicLit, ret)
 	case expr.CallExpr != nil:
-		return cg.EmitCallExpr(ctx, scope, expr.CallExpr, ret)
+		return ret.SetAsync(func(v Value) (Value, error) {
+			err := cg.lookupCall(ctx, scope, expr.CallExpr.Name.Ident)
+			if err != nil {
+				return nil, err
+			}
+
+			ret := NewRegister(ctx)
+			ret.Set(v)
+			err = cg.EmitCallExpr(ctx, scope, expr.CallExpr, ret)
+			return ret.Value(), err
+		})
 	default:
 		return errdefs.WithInternalErrorf(expr, "invalid expr")
 	}
@@ -249,9 +263,7 @@ func (cg *CodeGen) EmitCallExpr(ctx context.Context, scope *parser.Scope, call *
 		return err
 	}
 
-	// No type hint for arg evaluation because the call.Name hasn't been resolved
-	// yet, so codegen has no type information.
-	args, err := cg.Evaluate(ctx, scope, parser.None, nil, call.Args()...)
+	args, err := cg.Evaluate(ctx, scope, nil, call.Signature, call.Args()...)
 	if err != nil {
 		return err
 	}
@@ -280,11 +292,11 @@ func (cg *CodeGen) EmitIdentExpr(ctx context.Context, scope *parser.Scope, ie *p
 	case *parser.BindClause:
 		return cg.EmitBinding(ctx, n.TargetBinding(lookup.Text), args, ret)
 	case *parser.ImportDecl:
-		importScope, ok := obj.Data.(*parser.Scope)
+		imod, ok := obj.Data.(*parser.Module)
 		if !ok {
-			return errdefs.WithImportWithinImport(ie, obj.Ident)
+			return errdefs.WithInternalErrorf(ProgramCounter(ctx), "expected imported module to be resolved")
 		}
-		return cg.EmitIdentExpr(ctx, importScope, ie, ie.Reference.Ident, args, opts, nil, ret)
+		return cg.EmitIdentExpr(ctx, imod.Scope, ie, ie.Reference.Ident, args, opts, nil, ret)
 	case *parser.Field:
 		val, err := NewValue(ctx, obj.Data)
 		if err != nil {
@@ -308,8 +320,87 @@ func (cg *CodeGen) EmitIdentExpr(ctx context.Context, scope *parser.Scope, ie *p
 	}
 }
 
+func (cg *CodeGen) EmitImport(ctx context.Context, mod *parser.Module, id *parser.ImportDecl) (imod *parser.Module, filename string, err error) {
+	// Import expression can be string or fs.
+	ctx = WithReturnType(ctx, parser.None)
+
+	ret := NewRegister(ctx)
+	err = cg.EmitExpr(ctx, mod.Scope, id.Expr, nil, nil, nil, ret)
+	if err != nil {
+		return
+	}
+	val := ret.Value()
+
+	dir := mod.Directory
+	switch val.Kind() {
+	case parser.Filesystem:
+		var fs Filesystem
+		fs, err = val.Filesystem()
+		if err != nil {
+			return
+		}
+
+		filename = ModuleFilename
+		dir, err = cg.resolver.Resolve(ctx, id, fs)
+		if err != nil {
+			return
+		}
+	case parser.String:
+		filename, err = val.String()
+		if err != nil {
+			return
+		}
+	}
+
+	rc, err := dir.Open(filename)
+	if err != nil {
+		if !errdefs.IsNotExist(err) {
+			return
+		}
+		if id.DeprecatedPath != nil {
+			err = errdefs.WithImportPathNotExist(err, id.DeprecatedPath, filename)
+			return
+		}
+		if id.Expr.FuncLit != nil {
+			err = errdefs.WithImportPathNotExist(err, id.Expr.FuncLit.Type, filename)
+			return
+		}
+		err = errdefs.WithImportPathNotExist(err, id.Expr, filename)
+		return
+	}
+	defer rc.Close()
+
+	imod, err = parser.Parse(ctx, rc)
+	if err != nil {
+		return
+	}
+	imod.Directory = dir
+
+	err = checker.SemanticPass(imod)
+	if err != nil {
+		return
+	}
+
+	// Drop errors from linting.
+	_ = linter.Lint(ctx, imod)
+
+	err = checker.Check(imod)
+	return
+}
+
 func (cg *CodeGen) EmitBuiltinDecl(ctx context.Context, scope *parser.Scope, bd *parser.BuiltinDecl, args []Value, opts Option, b *parser.Binding, v Value) (Value, error) {
-	callable := bd.Callable(ReturnType(ctx))
+	var callable interface{}
+	if ReturnType(ctx) != parser.None {
+		callable = Callables[ReturnType(ctx)][bd.Name]
+	} else {
+		for _, kind := range bd.Kinds {
+			c, ok := Callables[kind][bd.Name]
+			if ok {
+				callable = c
+				break
+			}
+		}
+	}
 	if callable == nil {
 		return nil, errdefs.WithInternalErrorf(ProgramCounter(ctx), "unrecognized builtin `%s`", bd)
 	}
@@ -415,14 +506,57 @@ func (cg *CodeGen) EmitBinding(ctx context.Context, b *parser.Binding, args []Va
 	return cg.EmitFuncDecl(ctx, b.Bind.Closure, args, b, ret)
 }
 
+func (cg *CodeGen) lookupCall(ctx context.Context, scope *parser.Scope, lookup *parser.Ident) error {
+	obj := scope.Lookup(lookup.Text)
+	if obj == nil {
+		return errors.WithStack(errdefs.WithUndefinedIdent(lookup, nil))
+	}
+
+	switch n := obj.Node.(type) {
+	case *parser.ImportDecl:
+		_, ok := obj.Data.(*parser.Module)
+		if ok {
+			break
+		}
+
+		mod := scope.Root().Node.(*parser.Module)
+		imod, _, err := cg.EmitImport(ctx, mod, n)
+		if err != nil {
+			return err
+		}
+		obj.Data = imod
+
+		err = checker.CheckReferences(mod, n.Name.Text)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func (cg *CodeGen) EmitBlock(ctx context.Context, scope *parser.Scope, block *parser.BlockStmt, b *parser.Binding, ret Register) error {
-	ctx = WithReturnType(ctx, block.Kind())
+	if block == nil {
+		return nil
+	}
 
+	ctx = WithReturnType(ctx, block.Kind())
 	for _, stmt := range block.Stmts() {
+		stmt := stmt
 		var err error
 		switch {
 		case stmt.Call != nil:
-			err = cg.EmitCallStmt(ctx, scope, stmt.Call, b, ret)
+			err = ret.SetAsync(func(v Value) (Value, error) {
+				err := cg.lookupCall(ctx, scope, stmt.Call.Name.Ident)
+				if err != nil {
+					return nil, err
+				}
+
+				ret := NewRegister(ctx)
+				ret.Set(v)
+				err = cg.EmitCallStmt(ctx, scope, stmt.Call, b, ret)
+				return ret.Value(), err
+			})
 		case stmt.Expr != nil:
 			err = cg.EmitExpr(ctx, scope, stmt.Expr.Expr, nil, nil, b, ret)
 		default:
@@ -439,9 +573,7 @@ func (cg *CodeGen) EmitBlock(ctx context.Context, scope *parser.Scope, block *pa
 func (cg *CodeGen) EmitCallStmt(ctx context.Context, scope *parser.Scope, call *parser.CallStmt, b *parser.Binding, ret Register) error {
 	ctx = WithFrame(ctx, Frame{call.Name})
 
-	// No type hint for arg evaluation because the call.Name hasn't been resolved
-	// yet, so codegen has no type information.
-	args, err := cg.Evaluate(ctx, scope, parser.None, nil, call.Args...)
+	args, err := cg.Evaluate(ctx, scope, nil, call.Signature, call.Args...)
 	if err != nil {
 		return err
 	}
@@ -452,10 +584,12 @@ func (cg *CodeGen) EmitCallStmt(ctx context.Context, scope *parser.Scope, call *
 	var opts Option
 	if call.WithClause != nil {
 		// Provide a type hint to avoid ambgiuous lookups.
-		hint := parser.Kind(fmt.Sprintf("%s::%s", parser.Option, call.Name))
+		kinds := []parser.Kind{
+			parser.Kind(fmt.Sprintf("%s::%s", parser.Option, call.Name)),
+		}
 
 		// WithClause provides option expressions access to the binding.
-		values, err := cg.Evaluate(ctx, scope, hint, b, call.WithClause.Expr)
+		values, err := cg.Evaluate(ctx, scope, b, kinds, call.WithClause.Expr)
 		if err != nil {
 			return err
 		}
@@ -499,10 +633,13 @@ func (cg *CodeGen) EmitCallStmt(ctx context.Context, scope *parser.Scope, call *
 
 type breakpointCommand []string
 
-func (cg *CodeGen) Evaluate(ctx context.Context, scope *parser.Scope, hint parser.Kind, b *parser.Binding, exprs ...*parser.Expr) (values []Value, err error) {
-	for _, expr := range exprs {
+func (cg *CodeGen) Evaluate(ctx context.Context, scope *parser.Scope, b *parser.Binding, kinds []parser.Kind, exprs ...*parser.Expr) (values []Value, err error) {
+	if len(kinds) != len(exprs) {
+		return nil, errdefs.WithInternalErrorf(ProgramCounter(ctx), "expected %d kinds but got %d", len(exprs), len(kinds))
+	}
+	for i, expr := range exprs {
 		ctx = WithProgramCounter(ctx, expr)
-		ctx = WithReturnType(ctx, hint)
+		ctx = WithReturnType(ctx, kinds[i])
 
 		// Evaluated expressions write to a new return register.
 		ret := NewRegister(ctx)
diff --git a/codegen/resolver.go b/codegen/resolver.go
index a413f5a..295b09d 100644
--- a/codegen/resolver.go
+++ b/codegen/resolver.go
@@ -9,11 +9,24 @@ import (
 	"github.com/moby/buildkit/client/llb"
 	gateway "github.com/moby/buildkit/frontend/gateway/client"
 	digest "github.com/opencontainers/go-digest"
+	"github.com/openllb/hlb/parser"
 	"github.com/openllb/hlb/pkg/llbutil"
 	"github.com/openllb/hlb/solver"
 	"golang.org/x/sync/errgroup"
 )
 
+const (
+	// ModuleFilename is the filename of the HLB module expected to be in the
+	// solved filesystem provided to the import declaration.
+	ModuleFilename = "module.hlb"
+)
+
+// Resolver resolves imports into a reader ready for parsing and checking.
+type Resolver interface {
+	// Resolve returns a reader for the HLB module and its compiled LLB.
+	Resolve(ctx context.Context, id *parser.ImportDecl, fs Filesystem) (parser.Directory, error)
+}
+
 func NewCachedImageResolver(cln *client.Client) llb.ImageMetaResolver {
 	return &cachedImageResolver{
 		cln:   cln,
diff --git a/errdefs/errors.go b/errdefs/errors.go
index b9debc1..d2c0454 100644
--- a/errdefs/errors.go
+++ b/errdefs/errors.go
@@ -55,7 +55,10 @@ func WithDeprecated(mod *parser.Module, node parser.Node, format string, a ...in
 }
 
 func WithInternalErrorf(node parser.Node, format string, a ...interface{}) error {
-	return errors.WithStack(node.WithError(fmt.Errorf(format, a...)))
+	return node.WithError(
+		fmt.Errorf(format, a...),
+		node.Spanf(diagnostic.Primary, format, a...),
+	)
 }
 
 func WithInvalidCompileTarget(ident parser.Node) error {
@@ -227,14 +230,6 @@ func WithInvalidSharingMode(arg parser.Node, mode string, modes []string) error
 	)
 }
 
-func WithImportWithinImport(ie, decl parser.Node) error {
-	return ie.WithError(
-		fmt.Errorf("cannot use import within import"),
-		ie.Spanf(diagnostic.Primary, "cannot use import within import"),
-		decl.Spanf(diagnostic.Secondary, "imported here"),
-	)
-}
-
 func WithBindCacheMount(as, cache parser.Node) error {
 	return as.WithError(
 		fmt.Errorf("cannot bind a cache mount"),
diff --git a/hlb.go b/hlb.go
index 1eca660..5e5aee1 100644
--- a/hlb.go
+++ b/hlb.go
@@ -22,7 +22,7 @@ func Compile(ctx context.Context, cln *client.Client, mod *parser.Module, target
 		return nil, err
 	}
 
-	err = linter.Lint(ctx, mod, linter.WithRecursive())
+	err = linter.Lint(ctx, mod)
 	if err != nil {
 		for _, span := range diagnostic.Spans(err) {
 			fmt.Fprintln(os.Stderr, span.Pretty(ctx))
@@ -39,18 +39,7 @@ func Compile(ctx context.Context, cln *client.Client, mod *parser.Module, target
 		return nil, err
 	}
 
-	res, err := module.NewLocalResolved(mod)
-	if err != nil {
-		return nil, err
-	}
-	defer res.Close()
-
-	err = module.ResolveGraph(ctx, cln, resolver, res, mod, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	cg, err := codegen.New(cln, opts...)
+	cg, err := codegen.New(cln, resolver, opts...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/langserver/server.go b/langserver/server.go
index 34020d3..3d14983 100644
--- a/langserver/server.go
+++ b/langserver/server.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"log"
 	"path/filepath"
 	"sort"
@@ -15,8 +14,7 @@ import (
 	"github.com/creachadair/jrpc2"
 	"github.com/creachadair/jrpc2/channel"
 	"github.com/creachadair/jrpc2/handler"
-	"github.com/moby/buildkit/client/llb"
-	digest "github.com/opencontainers/go-digest"
+	"github.com/moby/buildkit/client"
 	"github.com/openllb/hlb/builtin"
 	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/codegen"
@@ -27,6 +25,9 @@ import (
 )
 
 type LangServer struct {
+	cln      *client.Client
+	resolver codegen.Resolver
+
 	server *jrpc2.Server
 	capset map[Capability]struct{}
 
@@ -44,11 +45,18 @@ const (
 	SemanticHighlightingCapability
 )
 
-func NewServer() *LangServer {
+func NewServer(ctx context.Context, cln *client.Client) (*LangServer, error) {
+	resolver, err := module.NewResolver(cln)
+	if err != nil {
+		return nil, err
+	}
+
 	ls := &LangServer{
-		capset: make(map[Capability]struct{}),
-		tds:    make(map[lsp.DocumentURI]TextDocument),
-		dbs:    make(map[lsp.DocumentURI]*debouncer),
+		cln:      cln,
+		resolver: resolver,
+		capset:   make(map[Capability]struct{}),
+		tds:      make(map[lsp.DocumentURI]TextDocument),
+		dbs:      make(map[lsp.DocumentURI]*debouncer),
 	}
 
 	ls.server = jrpc2.NewServer(handler.Map{
@@ -63,12 +71,15 @@ func NewServer() *LangServer {
 		"textDocument/completion": handler.New(ls.textDocumentCompletionHandler),
 	}, &jrpc2.ServerOptions{
 		AllowPush: true,
+		NewContext: func() context.Context {
+			return ctx
+		},
 	})
 
-	return ls
+	return ls, nil
 }
 
-func (ls *LangServer) Listen(ctx context.Context, r io.Reader, w io.WriteCloser) error {
+func (ls *LangServer) Listen(r io.Reader, w io.WriteCloser) error {
 	defer func() {
 		r := recover()
 		if r != nil {
@@ -133,7 +144,11 @@ func (ls *LangServer) textDocumentDidOpenHandler(ctx context.Context, params lsp
 	uri := params.TextDocument.URI
 	log.Printf("did open %q", uri)
 
-	td := NewTextDocument(ctx, uri, params.TextDocument.Text)
+	r := &parser.NamedReader{
+		Reader: strings.NewReader(params.TextDocument.Text),
+		Value:  strings.TrimPrefix(string(uri), "file://"),
+	}
+	td := NewTextDocument(ctx, uri, r, nil)
 
 	ls.tmu.Lock()
 	ls.tds[uri] = td
@@ -410,7 +425,11 @@ func (ls *LangServer) textDocumentDidChangeHandler(ctx context.Context, params l
 		}
 
 		for _, change := range params.ContentChanges {
-			td := NewTextDocument(ctx, uri, change.Text)
+			r := &parser.NamedReader{
+				Reader: strings.NewReader(change.Text),
+				Value:  strings.TrimPrefix(string(uri), "file://"),
+			}
+			td := NewTextDocument(ctx, uri, r, nil)
 			if _, ok := ls.capset[SemanticHighlightingCapability]; ok {
 				go func() {
 					err := ls.publishSemanticHighlighting(ctx, td)
@@ -533,67 +552,43 @@ func (ls *LangServer) textDocumentDefinitionHandler(ctx context.Context, params
 				return
 			}
 
-			cg, err := codegen.New(nil)
+			cg, err := codegen.New(ls.cln, ls.resolver)
 			if err != nil {
 				log.Printf("failed to create codegen: %s", err)
 				return
 			}
 
-			ret := codegen.NewRegister(ctx)
-			err = cg.EmitExpr(ctx, block.Scope, id.Expr, nil, nil, nil, ret)
+			ctx = codegen.WithProgramCounter(ctx, id.Expr)
+			imod, filename, err := cg.EmitImport(ctx, td.Module, id)
 			if err != nil {
-				log.Printf("failed to generate import: %s", err)
+				log.Printf("failed to emit import: %s", err)
 				return
 			}
-			val := ret.Value()
+			obj.Data = imod
 
-			rootDir := filepath.Dir(strings.TrimPrefix(string(uri), "file://"))
-			var filename string
-			switch val.Kind() {
-			case parser.Filesystem:
-				fs, err := val.Filesystem()
-				if err != nil {
-					return
-				}
-
-				def, err := fs.State.Marshal(ctx, llb.LinuxAmd64)
-				if err != nil {
-					log.Printf("failed to marshal import vertex: %s", err)
-					return
-				}
-
-				dgst := digest.FromBytes(def.Def[len(def.Def)-1])
-				vp := module.VendorPath(filepath.Join(rootDir, module.ModulesPath), dgst)
-				filename = filepath.Join(vp, module.ModuleFilename)
-			case parser.String:
-				localPath, err := val.String()
-				if err != nil {
-					return
-				}
-
-				filename = filepath.Join(rootDir, localPath)
-			default:
+			err = checker.CheckReferences(td.Module, id.Name.Text)
+			if err != nil {
+				log.Printf("failed to check references: %s", err)
 				return
 			}
 
-			importUri := lsp.DocumentURI(fmt.Sprintf("file://%s", filename))
-
 			ls.tmu.Lock()
-			importTD, ok := ls.tds[importUri]
+			importUri := lsp.DocumentURI(fmt.Sprintf("file://%s", filepath.Join(imod.Directory.Path(), filename)))
+			_, ok = ls.tds[importUri]
 			if !ok {
-				data, err := ioutil.ReadFile(filename)
+				rc, err := imod.Directory.Open(filename)
 				if err != nil {
-					log.Printf("failed to read file: %s", err)
+					log.Printf("failed to open file: %s", err)
 					ls.tmu.Unlock()
 					return
 				}
+				defer rc.Close()
 
-				importTD = NewTextDocument(ctx, importUri, string(data))
-				ls.tds[importUri] = importTD
+				ls.tds[importUri] = NewTextDocument(ctx, importUri, rc, imod.Directory)
 			}
 			ls.tmu.Unlock()
 
-			loc = newLocationFromIdent(importTD.Module.Scope, importUri, ie.Reference.Ident.Text)
+			loc = newLocationFromIdent(imod.Scope, importUri, ie.Reference.Ident.Text)
 		},
 	)
 
@@ -737,26 +732,26 @@ func newRangeFromNode(node parser.Node) lsp.Range {
 type TextDocument struct {
 	Identifier lsp.VersionedTextDocumentIdentifier
 	Module     *parser.Module
-	Text       string
 	Err        error
 }
 
-func NewTextDocument(ctx context.Context, uri lsp.DocumentURI, text string) TextDocument {
+func NewTextDocument(ctx context.Context, uri lsp.DocumentURI, r io.Reader, dir parser.Directory) TextDocument {
 	td := TextDocument{
 		Identifier: lsp.VersionedTextDocumentIdentifier{
 			TextDocumentIdentifier: lsp.TextDocumentIdentifier{
 				URI: uri,
 			},
 		},
-		Text: text,
 	}
 
-	td.Module, td.Err = parser.Parse(ctx, strings.NewReader(text))
+	td.Module, td.Err = parser.Parse(ctx, r)
 	if td.Err != nil {
 		log.Printf("failed to parse hlb: %s", td.Err)
 		return td
 	}
-	td.Module.Pos.Filename = strings.TrimPrefix(string(uri), "file://")
+	if dir != nil {
+		td.Module.Directory = dir
+	}
 
 	td.Err = checker.SemanticPass(td.Module)
 	if td.Err != nil {
diff --git a/linter/linter.go b/linter/linter.go
index 33b8107..05d8c90 100644
--- a/linter/linter.go
+++ b/linter/linter.go
@@ -2,28 +2,18 @@ package linter
 
 import (
 	"context"
-	"os"
 
-	"github.com/openllb/hlb/checker"
-	"github.com/openllb/hlb/codegen"
 	"github.com/openllb/hlb/diagnostic"
 	"github.com/openllb/hlb/errdefs"
 	"github.com/openllb/hlb/parser"
 )
 
 type Linter struct {
-	Recursive bool
-	errs      []error
+	errs []error
 }
 
 type LintOption func(*Linter)
 
-func WithRecursive() LintOption {
-	return func(l *Linter) {
-		l.Recursive = true
-	}
-}
-
 func Lint(ctx context.Context, mod *parser.Module, opts ...LintOption) error {
 	l := Linter{}
 	for _, opt := range opts {
@@ -51,9 +41,6 @@ func (l *Linter) Lint(ctx context.Context, mod *parser.Module) {
 					},
 				}
 			}
-			if l.Recursive {
-				l.LintRecursive(ctx, mod, id.Expr)
-			}
 		},
 		func(t *parser.Type) {
 			if string(t.Kind) == "group" {
@@ -75,51 +62,3 @@ func (l *Linter) Lint(ctx context.Context, mod *parser.Module) {
 		},
 	)
 }
-
-func (l *Linter) LintRecursive(ctx context.Context, mod *parser.Module, expr *parser.Expr) {
-	ctx = codegen.WithProgramCounter(ctx, mod)
-
-	cg, err := codegen.New(nil)
-	if err != nil {
-		return
-	}
-
-	ret := codegen.NewRegister(ctx)
-	err = cg.EmitExpr(ctx, mod.Scope, expr, nil, nil, nil, ret)
-	if err != nil {
-		return
-	}
-	val := ret.Value()
-
-	if val.Kind() != parser.String {
-		return
-	}
-
-	relPath, err := val.String()
-	if err != nil {
-		return
-	}
-
-	filename, err := parser.ResolvePath(codegen.ModuleDir(ctx), relPath)
-	if err != nil {
-		return
-	}
-
-	f, err := os.Open(filename)
-	if err != nil {
-		return
-	}
-	defer f.Close()
-
-	imod, err := parser.Parse(ctx, f)
-	if err != nil {
-		return
-	}
-
-	err = checker.SemanticPass(imod)
-	if err != nil {
-		return
-	}
-
-	l.Lint(ctx, imod)
-}
diff --git a/module/resolve.go b/module/resolve.go
index 79a13e3..c69216d 100644
--- a/module/resolve.go
+++ b/module/resolve.go
@@ -7,7 +7,6 @@ import (
 	"io"
 	"os"
 	"path/filepath"
-	"sync"
 
 	"github.com/docker/buildx/util/progress"
 	"github.com/moby/buildkit/client"
@@ -16,8 +15,6 @@ import (
 	digest "github.com/opencontainers/go-digest"
 	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/codegen"
-	"github.com/openllb/hlb/errdefs"
-	"github.com/openllb/hlb/linter"
 	"github.com/openllb/hlb/parser"
 	"github.com/openllb/hlb/pkg/llbutil"
 	"github.com/openllb/hlb/solver"
@@ -32,29 +29,11 @@ var (
 	// ModulesPath is the subdirectory of DotHLBPath that contains vendored
 	// modules.
 	ModulesPath = filepath.Join(DotHLBPath, "modules")
-
-	// ModuleFilename is the filename of the HLB module expected to be in the
-	// solved filesystem provided to the import declaration.
-	ModuleFilename = "module.hlb"
 )
 
-// Resolver resolves imports into a reader ready for parsing and checking.
-type Resolver interface {
-	// Resolve returns a reader for the HLB module and its compiled LLB.
-	Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (Resolved, error)
-}
-
-type Resolved interface {
-	Digest() digest.Digest
-
-	Open(filename string) (io.ReadCloser, error)
-
-	Close() error
-}
-
 // NewResolver returns a resolver based on whether the modules path exists in
 // the current working directory.
-func NewResolver(cln *client.Client) (Resolver, error) {
+func NewResolver(cln *client.Client) (codegen.Resolver, error) {
 	root, exist, err := modulesPathExist()
 	if err != nil {
 		return nil, err
@@ -91,65 +70,39 @@ type vendorResolver struct {
 	modulePath string
 }
 
-func (r *vendorResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (Resolved, error) {
-	res, err := resolveLocal(ctx, r.modulePath, fs)
+func (r *vendorResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (parser.Directory, error) {
+	dir, err := resolveLocal(ctx, r.modulePath, fs)
 	if err != nil {
-		return res, err
+		return dir, err
 	}
 
-	rc, err := res.Open(ModuleFilename)
+	rc, err := dir.Open(codegen.ModuleFilename)
 	if err == nil {
-		return res, rc.Close()
+		return dir, rc.Close()
 	}
 	if !os.IsNotExist(err) {
-		return res, err
+		return dir, err
 	}
 
-	return res, fmt.Errorf("missing module %q from vendor, run `hlb mod vendor --target %s %s` to vendor module", id.Name, id.Name, id.Pos.Filename)
+	return dir, fmt.Errorf("missing module %q from vendor, run `hlb mod vendor --target %s %s` to vendor module", id.Name, id.Name, id.Pos.Filename)
 }
 
-func resolveLocal(ctx context.Context, modulePath string, fs codegen.Filesystem) (Resolved, error) {
+func resolveLocal(ctx context.Context, modulePath string, fs codegen.Filesystem) (parser.Directory, error) {
 	dgst, err := fs.Digest(ctx)
 	if err != nil {
 		return nil, err
 	}
 
 	vp := VendorPath(modulePath, dgst)
-	return &localResolved{dgst, vp}, nil
+	return parser.NewLocalDirectory(vp, dgst), nil
 }
 
-type localResolved struct {
-	dgst digest.Digest
-	root string
-}
-
-func NewLocalResolved(mod *parser.Module) (Resolved, error) {
-	root, err := os.Getwd()
-	if err != nil {
-		return nil, err
-	}
-	return &localResolved{"", root}, nil
-}
-
-func (r *localResolved) Digest() digest.Digest {
-	return r.dgst
-}
-
-func (r *localResolved) Open(filename string) (io.ReadCloser, error) {
-	if filepath.IsAbs(filename) {
-		return os.Open(filename)
-	}
-	return os.Open(filepath.Join(r.root, filename))
-}
-
-func (r *localResolved) Close() error { return nil }
-
 type remoteResolver struct {
 	cln        *client.Client
 	modulePath string
 }
 
-func (r *remoteResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (Resolved, error) {
+func (r *remoteResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (parser.Directory, error) {
 	dgst, err := fs.Digest(ctx)
 	if err != nil {
 		return nil, err
@@ -166,11 +119,11 @@ func (r *remoteResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs
 		pw = mw.WithPrefix(fmt.Sprintf("import %s", id.Name), true)
 	}
 
-	// Block constructing remoteResolved until the graph is solved and assigned to
+	// Block constructing remoteDirectory until the graph is solved and assigned to
 	// ref.
 	resolved := make(chan struct{})
 
-	// Block solver.Build from exiting until remoteResolved is closed.
+	// Block solver.Build from exiting until remoteDirectory is closed.
 	// This ensures that cache keys and results from the build are not garbage
 	// collected.
 	closed := make(chan struct{})
@@ -189,14 +142,14 @@ func (r *remoteResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs
 	var ref gateway.Reference
 	g.Go(func() error {
 		return solver.Build(ctx, r.cln, s, pw, func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
-			res, err := c.Solve(ctx, gateway.SolveRequest{
+			dir, err := c.Solve(ctx, gateway.SolveRequest{
 				Definition: def.ToPB(),
 			})
 			if err != nil {
 				return nil, err
 			}
 
-			ref, err = res.SingleRef()
+			ref, err = dir.SingleRef()
 			if err != nil {
 				return nil, err
 			}
@@ -222,10 +175,10 @@ func (r *remoteResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs
 	}
 
 	root := fmt.Sprintf("%s#%s", id.Pos.Filename, id.Name)
-	return &remoteResolved{root, dgst, ref, g, ctx, closed}, nil
+	return &remoteDirectory{root, dgst, ref, g, ctx, closed}, nil
 }
 
-type remoteResolved struct {
+type remoteDirectory struct {
 	root   string
 	dgst   digest.Digest
 	ref    gateway.Reference
@@ -234,11 +187,16 @@ type remoteResolved struct {
 	closed chan struct{}
 }
 
-func (r *remoteResolved) Digest() digest.Digest {
+func (r *remoteDirectory) Path() string {
+	// TODO: cache imports in home dir
+	return ""
+}
+
+func (r *remoteDirectory) Digest() digest.Digest {
 	return r.dgst
 }
 
-func (r *remoteResolved) Open(filename string) (io.ReadCloser, error) {
+func (r *remoteDirectory) Open(filename string) (io.ReadCloser, error) {
 	_, err := r.ref.StatFile(r.ctx, gateway.StatRequest{
 		Path: filename,
 	})
@@ -259,7 +217,7 @@ func (r *remoteResolved) Open(filename string) (io.ReadCloser, error) {
 	}, nil
 }
 
-func (r *remoteResolved) Close() error {
+func (r *remoteDirectory) Close() error {
 	close(r.closed)
 	return r.g.Wait()
 }
@@ -269,19 +227,19 @@ type tidyResolver struct {
 	remote *remoteResolver
 }
 
-func (r *tidyResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (Resolved, error) {
-	res, err := resolveLocal(ctx, r.remote.modulePath, fs)
+func (r *tidyResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (parser.Directory, error) {
+	dir, err := resolveLocal(ctx, r.remote.modulePath, fs)
 	if err != nil {
-		return res, err
+		return dir, err
 	}
 
-	rc, err := res.Open(ModuleFilename)
+	rc, err := dir.Open(codegen.ModuleFilename)
 	if err == nil {
-		return res, rc.Close()
+		return dir, rc.Close()
 	}
 
 	if !os.IsNotExist(err) {
-		return res, err
+		return dir, err
 	}
 
 	return r.remote.Resolve(ctx, id, fs)
@@ -294,7 +252,7 @@ type targetResolver struct {
 	remote   *remoteResolver
 }
 
-func (r *targetResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (Resolved, error) {
+func (r *targetResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs codegen.Filesystem) (parser.Directory, error) {
 	if id.Pos.Filename == r.filename {
 		matchTarget := true
 		if len(r.targets) > 0 {
@@ -311,17 +269,17 @@ func (r *targetResolver) Resolve(ctx context.Context, id *parser.ImportDecl, fs
 		}
 	}
 
-	res, err := resolveLocal(ctx, r.remote.modulePath, fs)
+	dir, err := resolveLocal(ctx, r.remote.modulePath, fs)
 	if err != nil {
-		return res, err
+		return dir, err
 	}
 
-	rc, err := res.Open(ModuleFilename)
+	rc, err := dir.Open(codegen.ModuleFilename)
 	if err == nil {
-		return res, rc.Close()
+		return dir, rc.Close()
 	}
 	if !os.IsNotExist(err) {
-		return res, err
+		return dir, err
 	}
 
 	return r.remote.Resolve(ctx, id, fs)
@@ -343,116 +301,48 @@ type VisitInfo struct {
 	Parent     *parser.Module
 	Import     *parser.Module
 	ImportDecl *parser.ImportDecl
-	Value      codegen.Value
+	Filename   string
 	Digest     digest.Digest
 }
 
 type resolveGraphInfo struct {
-	cln      *client.Client
-	resolver Resolver
-	visitor  Visitor
+	cg      *codegen.CodeGen
+	visitor Visitor
 }
 
 // ResolveGraph traverses the import graph of a given module.
-func ResolveGraph(ctx context.Context, cln *client.Client, resolver Resolver, res Resolved, mod *parser.Module, visitor Visitor) error {
+func ResolveGraph(ctx context.Context, cln *client.Client, resolver codegen.Resolver, mod *parser.Module, visitor Visitor) error {
+	cg, err := codegen.New(cln, resolver)
+	if err != nil {
+		return err
+	}
+
 	info := &resolveGraphInfo{
-		cln:      cln,
-		resolver: resolver,
-		visitor:  visitor,
+		cg:      cg,
+		visitor: visitor,
 	}
-	return resolveGraph(ctx, info, res, mod)
+	return resolveGraph(ctx, info, mod)
 }
 
-func resolveGraph(ctx context.Context, info *resolveGraphInfo, res Resolved, mod *parser.Module) error {
+func resolveGraph(ctx context.Context, info *resolveGraphInfo, mod *parser.Module) error {
 	g, ctx := errgroup.WithContext(ctx)
 
-	var (
-		imports = make(map[string]*parser.Module)
-		mu      sync.Mutex
-	)
-
-	cg, err := codegen.New(info.cln)
-	if err != nil {
-		return err
-	}
-
 	parser.Match(mod, parser.MatchOpts{},
 		func(id *parser.ImportDecl) {
-			res := res
-			g.Go(func() error {
-				var (
-					ctx = codegen.WithProgramCounter(ctx, id.Expr)
-					ret = codegen.NewRegister(ctx)
-				)
-				err := cg.EmitExpr(ctx, mod.Scope, id.Expr, nil, nil, nil, ret)
-				if err != nil {
-					return err
-				}
-				val := ret.Value()
-
-				var filename string
-				switch val.Kind() {
-				case parser.Filesystem:
-					fs, err := val.Filesystem()
-					if err != nil {
-						return err
-					}
-
-					filename = ModuleFilename
-					res, err = info.resolver.Resolve(ctx, id, fs)
-					if err != nil {
-						return err
-					}
-					defer res.Close()
-
-				case parser.String:
-					filename, err = val.String()
-					if err != nil {
-						return err
-					}
-
-					if _, ok := res.(*localResolved); ok {
-						filename, err = parser.ResolvePath(codegen.ModuleDir(ctx), filename)
-						if err != nil {
-							return err
-						}
-					}
-				}
-
-				rc, err := res.Open(filename)
-				if err != nil {
-					if !errdefs.IsNotExist(err) {
-						return err
-					}
-					if id.DeprecatedPath != nil {
-						return errdefs.WithImportPathNotExist(err, id.DeprecatedPath, filename)
-					}
-					if id.Expr.FuncLit != nil {
-						return errdefs.WithImportPathNotExist(err, id.Expr.FuncLit.Type, filename)
-					}
-					return errdefs.WithImportPathNotExist(err, id.Expr, filename)
-				}
-				defer rc.Close()
-
-				imod, err := parser.Parse(ctx, rc)
-				if err != nil {
-					return err
-				}
-				defer func() {
-					mu.Lock()
-					imports[id.Name.Text] = imod
-					mu.Unlock()
-				}()
+			obj := mod.Scope.Lookup(id.Name.Text)
+			if obj == nil {
+				return
+			}
 
-				err = checker.SemanticPass(imod)
+			g.Go(func() error {
+				ctx = codegen.WithProgramCounter(ctx, id.Expr)
+				imod, filename, err := info.cg.EmitImport(ctx, mod, id)
 				if err != nil {
 					return err
 				}
+				obj.Data = imod
 
-				// Drop errors from linting.
-				_ = linter.Lint(ctx, imod)
-
-				err = checker.Check(imod)
+				err = checker.CheckReferences(mod, id.Name.Text)
 				if err != nil {
 					return err
 				}
@@ -462,32 +352,18 @@ func resolveGraph(ctx context.Context, info *resolveGraphInfo, res Resolved, mod
 						Parent:     mod,
 						Import:     imod,
 						ImportDecl: id,
-						Value:      val,
-						Digest:     res.Digest(),
+						Filename:   filename,
+						Digest:     imod.Directory.Digest(),
 					})
 					if err != nil {
 						return err
 					}
 				}
 
-				return resolveGraph(ctx, info, res, imod)
+				return resolveGraph(ctx, info, imod)
 			})
 		},
 	)
 
-	err = g.Wait()
-	if err != nil {
-		return err
-	}
-
-	// Register imported modules in the scope of the module that imported it.
-	for name, imp := range imports {
-		obj := mod.Scope.Lookup(name)
-		if obj == nil {
-			return fmt.Errorf("failed to find import %q", name)
-		}
-		obj.Data = imp.Scope
-	}
-
-	return checker.CheckReferences(mod)
+	return g.Wait()
 }
diff --git a/module/tree.go b/module/tree.go
index 71f7a74..2ed5373 100644
--- a/module/tree.go
+++ b/module/tree.go
@@ -20,12 +20,6 @@ func NewTree(ctx context.Context, cln *client.Client, mod *parser.Module, long b
 		return nil, err
 	}
 
-	res, err := NewLocalResolved(mod)
-	if err != nil {
-		return nil, err
-	}
-	defer res.Close()
-
 	var (
 		tree         = treeprint.New()
 		nodeByModule = make(map[*parser.Module]treeprint.Tree)
@@ -35,37 +29,22 @@ func NewTree(ctx context.Context, cln *client.Client, mod *parser.Module, long b
 	tree.SetValue(mod.Pos.Filename)
 	nodeByModule[mod] = tree
 
-	err = ResolveGraph(ctx, cln, resolver, res, mod, func(info VisitInfo) error {
-		var prefix string
+	err = ResolveGraph(ctx, cln, resolver, mod, func(info VisitInfo) error {
+		filename := info.Filename
 		if info.Digest != "" {
 			encoded := info.Digest.Encoded()
-			if !long && len(encoded) > 7 {
+			if len(encoded) > 7 {
 				encoded = encoded[:7]
 			}
-			prefix = fmt.Sprintf("%s:%s", info.Digest.Algorithm(), encoded)
-		}
-
-		var value string
-		switch info.Value.Kind() {
-		case parser.Filesystem:
-			value = filepath.Join(prefix, ModuleFilename)
-		case parser.String:
-			value, err = info.Value.String()
-			if err != nil {
-				return err
-			}
-
-			if prefix != "" {
-				value = filepath.Join(prefix, value)
-			}
+			prefix := fmt.Sprintf("%s:%s", info.Digest.Algorithm(), encoded)
+			filename = filepath.Join(prefix, filename)
 		}
 
 		mu.Lock()
 		node := nodeByModule[info.Parent]
-		inode := node.AddMetaBranch(info.ImportDecl.Name.Text, value)
+		inode := node.AddMetaBranch(info.ImportDecl.Name.Text, filename)
 		nodeByModule[info.Import] = inode
 		mu.Unlock()
-
 		return nil
 	})
 	return tree, err
diff --git a/module/vendor.go b/module/vendor.go
index 07e24ce..eea3315 100644
--- a/module/vendor.go
+++ b/module/vendor.go
@@ -7,6 +7,7 @@ import (
 	"sync"
 
 	"github.com/moby/buildkit/client"
+	"github.com/openllb/hlb/codegen"
 	"github.com/openllb/hlb/parser"
 	"golang.org/x/sync/errgroup"
 )
@@ -22,7 +23,7 @@ func Vendor(ctx context.Context, cln *client.Client, mod *parser.Module, targets
 	var mu sync.Mutex
 	markedPaths := make(map[string]struct{})
 
-	var resolver Resolver
+	var resolver codegen.Resolver
 	if tidy {
 		resolver = &tidyResolver{
 			cln:    cln,
@@ -37,16 +38,10 @@ func Vendor(ctx context.Context, cln *client.Client, mod *parser.Module, targets
 		}
 	}
 
-	res, err := NewLocalResolved(mod)
-	if err != nil {
-		return err
-	}
-	defer res.Close()
-
 	g, ctx := errgroup.WithContext(ctx)
 
 	ready := make(chan struct{})
-	err = ResolveGraph(ctx, cln, resolver, res, mod, func(info VisitInfo) error {
+	err := ResolveGraph(ctx, cln, resolver, mod, func(info VisitInfo) error {
 		g.Go(func() error {
 			<-ready
 
@@ -97,18 +92,7 @@ func Vendor(ctx context.Context, cln *client.Client, mod *parser.Module, targets
 				return err
 			}
 
-			var filename string
-			switch info.Value.Kind() {
-			case parser.Filesystem:
-				filename = ModuleFilename
-			case parser.String:
-				filename, err = info.Value.String()
-				if err != nil {
-					return err
-				}
-			}
-
-			f, err := os.Create(filepath.Join(vp, filename))
+			f, err := os.Create(filepath.Join(vp, info.Filename))
 			if err != nil {
 				return err
 			}
diff --git a/parser/cst.go b/parser/cst.go
index 6642864..d99575d 100644
--- a/parser/cst.go
+++ b/parser/cst.go
@@ -2,12 +2,14 @@ package parser
 
 import (
 	"fmt"
+	"io"
 	"strconv"
 	"strings"
 
 	"github.com/alecthomas/participle"
 	"github.com/alecthomas/participle/lexer"
 	"github.com/alecthomas/participle/lexer/stateful"
+	digest "github.com/opencontainers/go-digest"
 	"github.com/openllb/hlb/diagnostic"
 )
 
@@ -152,6 +154,18 @@ func splitKind(kind Kind) []string {
 	return strings.Split(string(kind), "::")
 }
 
+// Directory represents the abstract directory that modules can be read from.
+// The directory can be a local path or the filesystem of a remote import.
+type Directory interface {
+	Path() string
+
+	Digest() digest.Digest
+
+	Open(filename string) (io.ReadCloser, error)
+
+	Close() error
+}
+
 // Module represents a HLB source file. HLB is file-scoped, so every file
 // represents a module.
 //
@@ -160,9 +174,10 @@ func splitKind(kind Kind) []string {
 // fields without parser struct tags like scopes and doc linking.
 type Module struct {
 	Mixin
-	Scope *Scope
-	Doc   *CommentGroup
-	Decls []*Decl `parser:"@@*"`
+	Scope     *Scope
+	Directory Directory
+	Doc       *CommentGroup
+	Decls     []*Decl `parser:"@@*"`
 }
 
 // Decl represents a declaration node.
@@ -215,8 +230,8 @@ type Export struct {
 type BuiltinDecl struct {
 	*Module
 	Name           string
+	Kinds          []Kind
 	FuncDeclByKind map[Kind]*FuncDecl
-	CallableByKind map[Kind]Callable
 }
 
 func (bd *BuiltinDecl) FuncDecl(kind Kind) *FuncDecl {
@@ -234,23 +249,6 @@ func (bd *BuiltinDecl) FuncDecl(kind Kind) *FuncDecl {
 	return fun
 }
 
-func (bd *BuiltinDecl) Callable(kind Kind) Callable {
-	callable, ok := bd.CallableByKind[kind]
-	if ok {
-		return callable
-	}
-	if len(bd.CallableByKind) != 1 {
-		return nil
-	}
-	for _, c := range bd.CallableByKind {
-		callable = c
-		break
-	}
-	return callable
-}
-
-type Callable interface{}
-
 // FuncDecl represents a function declaration.
 type FuncDecl struct {
 	Mixin
@@ -444,7 +442,7 @@ type Stmt struct {
 type CallStmt struct {
 	Mixin
 	Doc        *CommentGroup
-	Callee     *FuncDecl
+	Signature  []Kind
 	Name       *IdentExpr  `parser:"@@"`
 	Args       []*Expr     `parser:"@@*"`
 	WithClause *WithClause `parser:"@@?"`
@@ -831,8 +829,9 @@ func NewBoolExpr(v bool) *Expr {
 // expression.
 type CallExpr struct {
 	Mixin
-	Name *IdentExpr `parser:"@@"`
-	List *ExprList  `parser:"@@?"`
+	Signature []Kind
+	Name      *IdentExpr `parser:"@@"`
+	List      *ExprList  `parser:"@@?"`
 }
 
 func (ce *CallExpr) Args() []*Expr {
diff --git a/parser/parse.go b/parser/parse.go
index a9d007b..99fbd6c 100644
--- a/parser/parse.go
+++ b/parser/parse.go
@@ -4,8 +4,11 @@ import (
 	"context"
 	"errors"
 	"io"
+	"os"
+	"path/filepath"
 
 	"github.com/alecthomas/participle/lexer"
+	digest "github.com/opencontainers/go-digest"
 	"github.com/openllb/hlb/diagnostic"
 	"github.com/openllb/hlb/pkg/filebuffer"
 	"golang.org/x/sync/errgroup"
@@ -32,7 +35,12 @@ func Parse(ctx context.Context, r io.Reader) (*Module, error) {
 		}()
 	}
 
-	return mod, Parser.Parse(name, r, mod)
+	err := Parser.Parse(name, r, mod)
+	if err != nil {
+		return nil, err
+	}
+	mod.Directory = NewLocalDirectory(filepath.Dir(mod.Pos.Filename), "")
+	return mod, nil
 }
 
 func ParseMultiple(ctx context.Context, rs []io.Reader) ([]*Module, error) {
@@ -94,3 +102,29 @@ func (nr *NewlinedReader) Read(p []byte) (n int, err error) {
 	}
 	return n, nil
 }
+
+type localDirectory struct {
+	root string
+	dgst digest.Digest
+}
+
+func NewLocalDirectory(root string, dgst digest.Digest) Directory {
+	return &localDirectory{root, dgst}
+}
+
+func (r *localDirectory) Path() string {
+	return r.root
+}
+
+func (r *localDirectory) Digest() digest.Digest {
+	return r.dgst
+}
+
+func (r *localDirectory) Open(filename string) (io.ReadCloser, error) {
+	if filepath.IsAbs(filename) {
+		return os.Open(filename)
+	}
+	return os.Open(filepath.Join(r.root, filename))
+}
+
+func (r *localDirectory) Close() error { return nil }
diff --git a/parser/scope.go b/parser/scope.go
index 6ed52b7..30141e1 100644
--- a/parser/scope.go
+++ b/parser/scope.go
@@ -61,7 +61,9 @@ func (s *Scope) Insert(obj *Object) {
 
 // Root returns the outer-most scope.
 func (s *Scope) Root() *Scope {
-	if s.Outer == nil {
+	// Outer-most scope is the one right before a global scope of all the
+	// builtins, which doesn't have a node defined.
+	if s.Outer.Node == nil {
 		return s
 	}
 	return s.Outer.Root()


Test patch:
diff --git a/checker/checker_test.go b/checker/checker_test.go
index fbf2532..5d56936 100644
--- a/checker/checker_test.go
+++ b/checker/checker_test.go
@@ -160,16 +160,6 @@ func TestChecker_Check(t *testing.T) {
 		}
 		`,
 		nil,
-	}, {
-		"import file",
-		`
-		import foo from "./go.hlb"
-
-		fs default() {
-			foo.bar
-		}
-		`,
-		nil,
 	}, {
 		"wrong number of args",
 		`
@@ -306,7 +296,7 @@ func TestChecker_Check(t *testing.T) {
 			)
 		},
 	}, {
-		"errors when a reference is called on a name that isn't an import",
+		"errors when a reference called on non-import",
 		`
 		fs myFunction() {}
 		fs badReferenceCaller() {
@@ -491,150 +481,6 @@ func TestChecker_Check(t *testing.T) {
 			if err == nil {
 				err = Check(mod)
 			}
-			if err == nil && tc.fn != nil {
-				err = CheckReferences(mod)
-				validateError(t, ctx, tc.fn(mod), err, tc.name)
-			} else {
-				var expected error
-				if tc.fn != nil {
-					expected = tc.fn(mod)
-				}
-				validateError(t, ctx, expected, err, tc.name)
-			}
-		})
-	}
-}
-
-func TestChecker_CheckReferences(t *testing.T) {
-	t.Parallel()
-
-	modFixture := `
-		export foo
-		export fooWithArgs
-		export resolveImage
-		fs foo() {}
-		fs bar() {}
-		fs fooWithArgs(string bar) {}
-		option::image resolveImage() { resolve; }
-	`
-
-	ctx := diagnostic.WithSources(context.Background(), builtin.Sources())
-	imod, err := parser.Parse(ctx, strings.NewReader(modFixture))
-	require.NoError(t, err)
-
-	err = SemanticPass(imod)
-	require.NoError(t, err)
-
-	err = Check(imod)
-	require.NoError(t, err)
-
-	for _, tc := range []testCase{{
-		"can call defined reference",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default() {
-			myImportedModule.foo
-		}
-		`,
-		nil,
-	}, {
-		"cannot call undefined reference",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default() {
-			myImportedModule.undefined
-		}
-		`,
-		func(mod *parser.Module) error {
-			return errdefs.WithUndefinedIdent(
-				parser.Find(mod, "undefined"),
-				nil,
-				errdefs.Imported(parser.Find(mod, "myImportedModule")),
-			)
-		},
-	}, {
-		"unable to call unexported functions",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default() {
-			myImportedModule.bar
-		}
-		`,
-		func(mod *parser.Module) error {
-			return errdefs.WithCallUnexported(
-				parser.Find(mod, "bar"),
-				errdefs.Imported(parser.Find(mod, "myImportedModule")),
-			)
-		},
-	}, {
-		"able to use valid reference as mount input",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default() {
-			scratch
-			run "xyz" with option {
-				mount myImportedModule.foo "/mountpoint"
-			}
-		}
-		`,
-		nil,
-	}, {
-		"able to pass function field as argument to reference",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default(string foo) {
-			myImportedModule.fooWithArgs foo
-		}
-		`,
-		nil,
-	}, {
-		"use imported option",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default(string foo) {
-			image "busybox" with myImportedModule.resolveImage
-		}
-		`,
-		nil,
-	}, {
-		"merge imported option",
-		`
-		import myImportedModule from "./myModule.hlb"
-
-		fs default(string foo) {
-			image "busybox" with option {
-				myImportedModule.resolveImage
-			}
-		}
-		`,
-		nil,
-	}} {
-		tc := tc
-		t.Run(tc.name, func(t *testing.T) {
-			in := strings.NewReader(dedent.Dedent(tc.input))
-
-			mod, err := parser.Parse(ctx, in)
-			require.NoError(t, err)
-
-			err = SemanticPass(mod)
-			require.NoError(t, err)
-
-			err = Check(mod)
-			require.NoError(t, err)
-
-			obj := mod.Scope.Lookup("myImportedModule")
-			if obj == nil {
-				t.Fatal("myImportedModule should be imported for this test to work")
-			}
-			obj.Data = imod.Scope
-
-			err = CheckReferences(mod)
 			var expected error
 			if tc.fn != nil {
 				expected = tc.fn(mod)
@@ -647,8 +493,18 @@ func TestChecker_CheckReferences(t *testing.T) {
 func validateError(t *testing.T, ctx context.Context, expected, actual error, name string) {
 	switch {
 	case expected == nil:
+		if actual != nil {
+			for _, span := range diagnostic.Spans(actual) {
+				t.Logf("[Actual]\n%s", span.Pretty(ctx))
+			}
+		}
 		require.NoError(t, actual, name)
 	case actual == nil:
+		if expected != nil {
+			for _, span := range diagnostic.Spans(expected) {
+				t.Logf("[Expected]\n%s", span.Pretty(ctx))
+			}
+		}
 		require.NotNil(t, actual, name)
 	default:
 		espans := diagnostic.Spans(expected)
diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go
index f5e10b9..6b8da92 100644
--- a/codegen/codegen_test.go
+++ b/codegen/codegen_test.go
@@ -2,6 +2,7 @@ package codegen_test
 
 import (
 	"context"
+	"errors"
 	"os"
 	"path/filepath"
 	"strings"
@@ -18,7 +19,7 @@ import (
 	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/codegen"
 	"github.com/openllb/hlb/diagnostic"
-	"github.com/openllb/hlb/linter"
+	"github.com/openllb/hlb/errdefs"
 	"github.com/openllb/hlb/local"
 	"github.com/openllb/hlb/parser"
 	"github.com/openllb/hlb/pkg/llbutil"
@@ -60,16 +61,17 @@ func LocalState(ctx context.Context, t *testing.T, localPath string, opts ...llb
 	return llb.Local(localPath, opts...)
 }
 
-type testCase struct {
-	name      string
-	targets   []string
-	hlb       string
-	hlbImport string
-	fn        func(ctx context.Context, t *testing.T) solver.Request
-}
-
 func TestCodeGen(t *testing.T) {
 	t.Parallel()
+
+	type testCase struct {
+		name      string
+		targets   []string
+		hlb       string
+		hlbImport string
+		fn        func(ctx context.Context, t *testing.T) solver.Request
+	}
+
 	for _, tc := range []testCase{{
 		"image",
 		[]string{"default"},
@@ -469,21 +471,21 @@ func TestCodeGen(t *testing.T) {
 			).Root())
 		},
 	}, {
-		"empty group",
+		"empty pipeline",
 		[]string{"default"},
 		`
-		group default() {}
+		pipeline default() {}
 		`, "",
 		func(ctx context.Context, t *testing.T) solver.Request {
 			return solver.NilRequest()
 		},
 	}, {
-		"sequential group",
+		"sequential pipeline",
 		[]string{"default"},
 		`
-		group default() {
-			parallel fs { image "alpine"; }
-			parallel fs { image "busybox"; }
+		pipeline default() {
+			stage fs { image "alpine"; }
+			stage fs { image "busybox"; }
 		}
 		`, "",
 		func(ctx context.Context, t *testing.T) solver.Request {
@@ -493,11 +495,11 @@ func TestCodeGen(t *testing.T) {
 			)
 		},
 	}, {
-		"parallel group",
+		"stage pipeline",
 		[]string{"default"},
 		`
-		group default() {
-			parallel fs {
+		pipeline default() {
+			stage fs {
 				image "alpine"
 			} fs {
 				image "busybox"
@@ -511,17 +513,17 @@ func TestCodeGen(t *testing.T) {
 			)
 		},
 	}, {
-		"parallel and sequential groups",
+		"stage and sequential pipelines",
 		[]string{"default"},
 		`
-		group default() {
-			parallel fs { image "golang:alpine"; }
-			parallel fs {
+		pipeline default() {
+			stage fs { image "golang:alpine"; }
+			stage fs {
 				image "alpine"
 			} fs {
 				image "busybox"
 			}
-			parallel fs { image "node:alpine"; }
+			stage fs { image "node:alpine"; }
 		}
 		`, "",
 		func(ctx context.Context, t *testing.T) solver.Request {
@@ -535,18 +537,18 @@ func TestCodeGen(t *testing.T) {
 			)
 		},
 	}, {
-		"invoking group functions",
+		"invoking pipeline functions",
 		[]string{"default"},
 		`
-		group default() {
+		pipeline default() {
 			foo "stable"
 		}
 
-		group foo(string ref) {
-			parallel fs {
+		pipeline foo(string ref) {
+			stage fs {
 				image string { format "alpine:%s" ref; }
 			}
-			parallel fs {
+			stage fs {
 				image string { format "busybox:%s" ref; }
 			}
 		}
@@ -558,11 +560,11 @@ func TestCodeGen(t *testing.T) {
 			)
 		},
 	}, {
-		"parallel coercing fs to group",
+		"stage coercing fs to pipeline",
 		[]string{"default"},
 		`
-		group default() {
-			parallel fs {
+		pipeline default() {
+			stage fs {
 				image "alpine"
 			} fs {
 				scratch
@@ -930,8 +932,6 @@ func TestCodeGen(t *testing.T) {
 			err = checker.SemanticPass(mod)
 			require.NoError(t, err, tc.name)
 
-			_ = linter.Lint(ctx, mod)
-
 			err = checker.Check(mod)
 			require.NoError(t, err, tc.name)
 
@@ -947,14 +947,12 @@ func TestCodeGen(t *testing.T) {
 				err = checker.SemanticPass(imod)
 				require.NoError(t, err, tc.name)
 
-				_ = linter.Lint(ctx, imod)
-
 				err = checker.Check(imod)
 				require.NoError(t, err, tc.name)
 
-				obj.Data = imod.Scope
+				obj.Data = imod
 
-				err = checker.CheckReferences(mod)
+				err = checker.CheckReferences(mod, "other")
 				require.NoError(t, err, tc.name)
 			}
 
@@ -963,7 +961,7 @@ func TestCodeGen(t *testing.T) {
 				targets = append(targets, codegen.Target{Name: target})
 			}
 
-			cg, err := codegen.New(nil)
+			cg, err := codegen.New(nil, nil)
 			require.NoError(t, err, tc.name)
 
 			ctx = codegen.WithSessionID(ctx, identity.NewID())
@@ -991,27 +989,14 @@ func TestCodeGen(t *testing.T) {
 func TestCodegenError(t *testing.T) {
 	t.Parallel()
 
-	type errorTestCase struct {
-		name          string
-		targets       []string
-		input         string
-		expectedError string
+	type testCase struct {
+		name    string
+		targets []string
+		input   string
+		fn      func(*parser.Module) error
 	}
 
-	for _, tc := range []errorTestCase{
-		{
-			"invalid builtin",
-			[]string{"default"},
-			`
-			fs default() {
-				bug includePatterns("*.go")
-			}
-			fs bug(option::local pattern) {
-				local "." with pattern
-			}
-		`,
-			"<stdin>:3:9: unrecognized builtin `includePatterns`",
-		},
+	for _, tc := range []testCase{
 		{
 			"invalid downloadDockerTarball ref",
 			[]string{"default"},
@@ -1019,8 +1004,14 @@ func TestCodegenError(t *testing.T) {
 			fs default() {
 				downloadDockerTarball "image.tar" "#"
 			}
-		`,
-			"<stdin>:3:39: failed to parse `#`: invalid reference format",
+			`,
+			func(mod *parser.Module) error {
+				return errdefs.WithInvalidImageRef(
+					errors.New("invalid reference format"),
+					parser.Find(mod, `"#"`),
+					"#",
+				)
+			},
 		},
 	} {
 		tc := tc
@@ -1032,8 +1023,6 @@ func TestCodegenError(t *testing.T) {
 			err = checker.SemanticPass(mod)
 			require.NoError(t, err, tc.name)
 
-			_ = linter.Lint(ctx, mod)
-
 			err = checker.Check(mod)
 			require.NoError(t, err, tc.name)
 
@@ -1042,12 +1031,275 @@ func TestCodegenError(t *testing.T) {
 				targets = append(targets, codegen.Target{Name: target})
 			}
 
-			cg, err := codegen.New(nil)
+			cg, err := codegen.New(nil, nil)
 			require.NoError(t, err, tc.name)
 
 			ctx = codegen.WithSessionID(ctx, identity.NewID())
 			_, err = cg.Generate(ctx, mod, targets)
-			require.EqualError(t, err, tc.expectedError)
+			var expected error
+			if tc.fn != nil {
+				expected = tc.fn(mod)
+			}
+			validateError(t, ctx, expected, err, tc.name)
 		})
 	}
 }
+
+func TestCodeGenImport(t *testing.T) {
+	t.Parallel()
+
+	type testImport struct {
+		filename string
+		content  string
+	}
+
+	type testCase struct {
+		name    string
+		hlb     string
+		imports []testImport
+		fn      func(*parser.Module) error
+	}
+
+	for _, tc := range []testCase{{
+		"can call defined reference",
+		`
+		import other from "./other.hlb"
+
+		fs default() {
+			other.foo
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			fs foo()
+			`,
+		}},
+		nil,
+	}, {
+		"cannot call undefined reference",
+		`
+		import other from "./other.hlb"
+
+		fs default() {
+			other.bar
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			fs foo()
+			`,
+		}},
+		func(mod *parser.Module) error {
+			return errdefs.WithUndefinedIdent(
+				parser.Find(mod, "bar"),
+				nil,
+				errdefs.Imported(parser.Find(mod, "other")),
+			)
+		},
+	}, {
+		"unable to call unexported function",
+		`
+		import other from "./other.hlb"
+
+		fs default() {
+			other.foo
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			fs foo()
+			`,
+		}},
+		func(mod *parser.Module) error {
+			return errdefs.WithCallUnexported(
+				parser.Find(mod, "foo"),
+				errdefs.Imported(parser.Find(mod, "other")),
+			)
+		},
+	}, {
+		"able to use valid reference as mount input",
+		`
+		import other from "./other.hlb"
+
+		fs default() {
+			run "xyz" with option {
+				mount other.foo "/in"
+			}
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			fs foo()
+			`,
+		}},
+		nil,
+	}, {
+		"able to use pass function field as argument to reference",
+		`
+		import other from "./other.hlb"
+
+		fs default(string bar) {
+			other.foo bar
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			fs foo(string bar)
+			`,
+		}},
+		nil,
+	}, {
+		"able to use imported option",
+		`
+		import other from "./other.hlb"
+
+		fs default(string bar) {
+			image "busybox" with other.foo
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			option::image foo()
+			`,
+		}},
+		nil,
+	}, {
+		"able to append option",
+		`
+		import other from "./other.hlb"
+
+		fs default(string bar) {
+			image "busybox" with option {
+				other.foo
+			}
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export foo
+			option::image foo()
+			`,
+		}},
+		nil,
+	}, {
+		"able to import within import",
+		`
+		import other from "./other.hlb"
+		import other2 from other.wrap("./other2.hlb")
+
+		fs default() {
+			other2.build
+		}
+		`,
+		[]testImport{{
+			"other.hlb",
+			`
+			export wrap
+			string wrap(string path) {
+				path
+			}
+			`,
+		}, {
+			"other2.hlb",
+			`
+			export build
+			fs build()
+			`,
+		}},
+		nil,
+	}} {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := diagnostic.WithSources(context.Background(), builtin.Sources())
+			r := &parser.NamedReader{
+				Reader: strings.NewReader(dedent.Dedent(tc.hlb)),
+				Value:  "build.hlb",
+			}
+			mod, err := parser.Parse(ctx, r)
+			require.NoError(t, err, tc.name)
+
+			err = checker.SemanticPass(mod)
+			require.NoError(t, err, tc.name)
+
+			err = checker.Check(mod)
+			require.NoError(t, err, tc.name)
+
+			var actual error
+			for _, imp := range tc.imports {
+				name := strings.TrimSuffix(imp.filename, ".hlb")
+				obj := mod.Scope.Lookup(name)
+				if obj == nil {
+					t.Fatalf(`%q should be imported by the test module`, name)
+				}
+
+				ir := &parser.NamedReader{
+					Reader: strings.NewReader(dedent.Dedent(imp.content)),
+					Value:  imp.filename,
+				}
+				imod, err := parser.Parse(ctx, ir)
+				require.NoError(t, err, tc.name)
+
+				err = checker.SemanticPass(imod)
+				require.NoError(t, err, tc.name)
+
+				err = checker.Check(imod)
+				require.NoError(t, err, tc.name)
+
+				obj.Data = imod
+				err = checker.CheckReferences(mod, name)
+				if err != nil {
+					actual = err
+				}
+			}
+
+			var expected error
+			if tc.fn != nil {
+				expected = tc.fn(mod)
+			}
+			validateError(t, ctx, expected, actual, tc.name)
+		})
+	}
+}
+
+func validateError(t *testing.T, ctx context.Context, expected, actual error, name string) {
+	switch {
+	case expected == nil:
+		if actual != nil {
+			for _, span := range diagnostic.Spans(actual) {
+				t.Logf("[Actual]\n%s", span.Pretty(ctx))
+			}
+		}
+		require.NoError(t, actual, name)
+	case actual == nil:
+		if expected != nil {
+			for _, span := range diagnostic.Spans(expected) {
+				t.Logf("[Expected]\n%s", span.Pretty(ctx))
+			}
+		}
+		require.NotNil(t, actual, name)
+	default:
+		espans := diagnostic.Spans(expected)
+		aspans := diagnostic.Spans(actual)
+		require.Equal(t, len(espans), len(aspans))
+
+		for i := 0; i < len(espans); i++ {
+			epretty := espans[i].Pretty(ctx)
+			t.Logf("[Expected]\n%s", epretty)
+			apretty := aspans[i].Pretty(ctx)
+			t.Logf("[Actual]\n%s", apretty)
+			require.Equal(t, epretty, apretty, name)
+		}
+	}
+}
diff --git a/linter/linter_test.go b/linter/linter_test.go
index 0763c7f..eb4d95a 100644
--- a/linter/linter_test.go
+++ b/linter/linter_test.go
@@ -24,7 +24,7 @@ func TestLinter_Lint(t *testing.T) {
 	t.Parallel()
 
 	for _, tc := range []testCase{{
-		"empty",
+		"import without from",
 		`
 		import foo "./foo.hlb"
 		`,
@@ -34,6 +34,30 @@ func TestLinter_Lint(t *testing.T) {
 				`import path without keyword "from" is deprecated`,
 			)
 		},
+	}, {
+		"group and parallel",
+		`
+		group default() {
+			parallel foo bar
+		}
+
+		fs foo()
+		fs bar()
+		`,
+		func(mod *parser.Module) error {
+			return &diagnostic.Error{
+				Diagnostics: []error{
+					errdefs.WithDeprecated(
+						mod, parser.Find(mod, "group"),
+						"type `group` is deprecated, use `pipeline` instead",
+					),
+					errdefs.WithDeprecated(
+						mod, parser.Find(mod, "parallel"),
+						"function `parallel` is deprecated, use `stage` instead",
+					),
+				},
+			}
+		},
 	}} {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
@@ -46,12 +70,11 @@ func TestLinter_Lint(t *testing.T) {
 			err = checker.SemanticPass(mod)
 			require.NoError(t, err)
 
-			err = Lint(ctx, mod)
-
 			var expected error
 			if tc.fn != nil {
 				expected = tc.fn(mod)
 			}
+			err = Lint(ctx, mod)
 			validateError(t, ctx, expected, err, tc.name)
 		})
 	}
diff --git a/module/resolve_test.go b/module/resolve_test.go
index 25309fe..ca558ff 100644
--- a/module/resolve_test.go
+++ b/module/resolve_test.go
@@ -14,7 +14,6 @@ import (
 	"github.com/openllb/hlb/checker"
 	"github.com/openllb/hlb/diagnostic"
 	"github.com/openllb/hlb/errdefs"
-	"github.com/openllb/hlb/linter"
 	"github.com/openllb/hlb/parser"
 	"github.com/stretchr/testify/require"
 )
@@ -25,15 +24,19 @@ type testCase struct {
 	fn    func(mod *parser.Module, imods map[string]*parser.Module) error
 }
 
-type testResolved struct {
+type testDirectory struct {
 	fixtures map[string]string
 }
 
-func (r *testResolved) Digest() digest.Digest {
+func (r *testDirectory) Path() string {
 	return ""
 }
 
-func (r *testResolved) Open(filename string) (io.ReadCloser, error) {
+func (r *testDirectory) Digest() digest.Digest {
+	return ""
+}
+
+func (r *testDirectory) Open(filename string) (io.ReadCloser, error) {
 	fixture, ok := r.fixtures[filename]
 	if !ok {
 		return nil, os.ErrNotExist
@@ -41,14 +44,14 @@ func (r *testResolved) Open(filename string) (io.ReadCloser, error) {
 	return ioutil.NopCloser(strings.NewReader(fixture)), nil
 }
 
-func (r *testResolved) Close() error {
+func (r *testDirectory) Close() error {
 	return nil
 }
 
 func TestResolveGraph(t *testing.T) {
 	t.Parallel()
 
-	res := &testResolved{map[string]string{
+	dir := &testDirectory{map[string]string{
 		"simple.hlb": `
 			export build
 			fs build() {}
@@ -114,18 +117,16 @@ func TestResolveGraph(t *testing.T) {
 			ctx := diagnostic.WithSources(context.Background(), builtin.Sources())
 			mod, err := parser.Parse(ctx, in)
 			require.NoError(t, err)
+			mod.Directory = dir
 
 			err = checker.SemanticPass(mod)
 			require.NoError(t, err)
 
-			err = linter.Lint(ctx, mod)
-			require.NoError(t, err)
-
 			err = checker.Check(mod)
 			require.NoError(t, err)
 
 			imods := make(map[string]*parser.Module)
-			err = ResolveGraph(ctx, nil, nil, res, mod, func(info VisitInfo) error {
+			err = ResolveGraph(ctx, nil, nil, mod, func(info VisitInfo) error {
 				imods[info.ImportDecl.Name.Text] = info.Import
 				return nil
 			})