Repository: uber/prototool
Pull request #258 (2018-12-26 19:55:33)

Inspect commands
This adds a bunch of inspection commands to print out data about packages. We can extend this in the future to include other items (messages within packages, files, etc).  We need most of this functionality anyways for the breaking change detector and there was an ask for it, so we might as well expose it.

Tldr:

```
$ cd example

$ prototool inspect packages
foo
google.protobuf
sub

$ prototool inspect packages --json
{
  "name": "foo",
  "deps": [
    "google.protobuf",
    "sub"
  ]
}
{
  "name": "google.protobuf",
  "importers": [
    "foo"
  ]
}
{
  "name": "sub",
  "importers": [
    "foo"
  ]
}

$ prototool inspect package --name foo
{
  "name": "foo",
  "deps": [
    "google.protobuf",
    "sub"
  ]
}

$ prototool inspect package-deps --name foo
google.protobuf
sub

$ prototool inspect package-importers --name sub
foo
```

codecov-io: # [Codecov](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=h1) Report
> Merging [#258](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=desc) into [dev](https://codecov.io/gh/uber/prototool/commit/d750e56dec5c7976a36b245360e7467803e45f88?src=pr&el=desc) will **decrease** coverage by `0.38%`.
> The diff coverage is `62.72%`.

[![Impacted file tree graph](https://codecov.io/gh/uber/prototool/pull/258/graphs/tree.svg?width=650&token=A9V0DApqZg&height=150&src=pr)](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##              dev     #258      +/-   ##
==========================================
- Coverage   71.08%   70.69%   -0.39%     
==========================================
  Files          90       90              
  Lines        4509     4729     +220     
==========================================
+ Hits         3205     3343     +138     
- Misses        950      991      +41     
- Partials      354      395      +41
```


| [Impacted Files](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [internal/exec/exec.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZXhlYy9leGVjLmdv) | `57.14% <ø> (ø)` | :arrow_up: |
| [internal/cmd/flags.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvY21kL2ZsYWdzLmdv) | `100% <100%> (ø)` | :arrow_up: |
| [internal/cmd/cmd.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvY21kL2NtZC5nbw==) | `69.86% <100%> (+2.69%)` | :arrow_up: |
| [internal/cmd/templates.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvY21kL3RlbXBsYXRlcy5nbw==) | `83.45% <100%> (+3.01%)` | :arrow_up: |
| [internal/exec/runner.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZXhlYy9ydW5uZXIuZ28=) | `47.72% <37.63%> (-1.67%)` | :arrow_down: |
| [internal/extract/extract.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZXh0cmFjdC9leHRyYWN0Lmdv) | `75% <66.66%> (-25%)` | :arrow_down: |
| [internal/extract/getter.go](https://codecov.io/gh/uber/prototool/pull/258/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZXh0cmFjdC9nZXR0ZXIuZ28=) | `63.68% <69.84%> (+3.05%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=footer). Last update [d750e56...545e2c9](https://codecov.io/gh/uber/prototool/pull/258?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go
index 0587c90..4c08723 100644
--- a/internal/cmd/cmd.go
+++ b/internal/cmd/cmd.go
@@ -112,6 +112,12 @@ func getRootCommand(exitCodeAddr *int, develMode bool, args []string, stdin io.R
 	rootCmd.AddCommand(configCmd)
 	rootCmd.AddCommand(lintCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
 	rootCmd.AddCommand(versionCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
+	inspectCmd := &cobra.Command{Use: "inspect", Short: "Top-level command for inspection commands."}
+	inspectCmd.AddCommand(inspectPackagesCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
+	inspectCmd.AddCommand(inspectPackageCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
+	inspectCmd.AddCommand(inspectPackageDepsCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
+	inspectCmd.AddCommand(inspectPackageImportersCmdTemplate.Build(exitCodeAddr, stdin, stdout, stderr, flags))
+	rootCmd.AddCommand(inspectCmd)
 
 	// flags bound to rootCmd are global flags
 	flags.bindDebug(rootCmd.PersistentFlags())
diff --git a/internal/cmd/flags.go b/internal/cmd/flags.go
index 68d4f13..09bf9cb 100644
--- a/internal/cmd/flags.go
+++ b/internal/cmd/flags.go
@@ -46,6 +46,7 @@ type flags struct {
 	listLintGroup     string
 	lintMode          bool
 	method            string
+	name              string
 	overwrite         bool
 	pkg               string
 	printFields       string
@@ -136,6 +137,10 @@ func (f *flags) bindMethod(flagSet *pflag.FlagSet) {
 	flagSet.StringVar(&f.method, "method", "", "The GRPC method to call in the form package.Service/Method. This is required.")
 }
 
+func (f *flags) bindName(flagSet *pflag.FlagSet) {
+	flagSet.StringVar(&f.name, "name", "", "The package name. This is required.")
+}
+
 func (f *flags) bindOverwrite(flagSet *pflag.FlagSet) {
 	flagSet.BoolVarP(&f.overwrite, "overwrite", "w", false, "Overwrite the existing file instead of writing the formatted file to stdout.")
 }
diff --git a/internal/cmd/templates.go b/internal/cmd/templates.go
index a8d6b57..806befb 100644
--- a/internal/cmd/templates.go
+++ b/internal/cmd/templates.go
@@ -253,7 +253,7 @@ If Vim integration is set up, files will be generated when you open a new Protob
 
 	grpcCmdTemplate = &cmdTemplate{
 		Use:   "grpc [dirOrFile]",
-		Short: "Call a gRPC endpoint. Be sure to set required flags address, method, and either data or stdin.",
+		Short: "Call a gRPC endpoint. Be sure to set the required flags address, method, and either data or stdin.",
 		Long: `This command compiles your proto files with "protoc", converts JSON input to binary and converts the result from binary to JSON. All these steps take on the order of milliseconds. For example, the overhead for a file with four dependencies is about 30ms, so there is little overhead for CLI calls to gRPC.
 
 There is a full example for gRPC in the example directory of Prototool. Run "make init example" to make sure everything is installed and generated.
@@ -342,6 +342,70 @@ $ cat input.json | prototool grpc example \
 		},
 	}
 
+	inspectPackagesCmdTemplate = &cmdTemplate{
+		Use:   "packages [dirOrFile]",
+		Short: "List all packages.",
+		Args:  cobra.MaximumNArgs(1),
+		Run: func(runner exec.Runner, args []string, flags *flags) error {
+			return runner.InspectPackages(args)
+		},
+		BindFlags: func(flagSet *pflag.FlagSet, flags *flags) {
+			flags.bindConfigData(flagSet)
+			flags.bindJSON(flagSet)
+			flags.bindProtocURL(flagSet)
+			flags.bindProtocBinPath(flagSet)
+			flags.bindProtocWKTPath(flagSet)
+		},
+	}
+
+	inspectPackageCmdTemplate = &cmdTemplate{
+		Use:   "package [dirOrFile]",
+		Short: "Print the given package in JSON format. Be sure to set the required flag name.",
+		Args:  cobra.MaximumNArgs(1),
+		Run: func(runner exec.Runner, args []string, flags *flags) error {
+			return runner.InspectPackage(args, flags.name)
+		},
+		BindFlags: func(flagSet *pflag.FlagSet, flags *flags) {
+			flags.bindConfigData(flagSet)
+			flags.bindName(flagSet)
+			flags.bindProtocURL(flagSet)
+			flags.bindProtocBinPath(flagSet)
+			flags.bindProtocWKTPath(flagSet)
+		},
+	}
+
+	inspectPackageDepsCmdTemplate = &cmdTemplate{
+		Use:   "package-deps [dirOrFile]",
+		Short: "Print the given package dependencies. Be sure to set the required flag name.",
+		Args:  cobra.MaximumNArgs(1),
+		Run: func(runner exec.Runner, args []string, flags *flags) error {
+			return runner.InspectPackageDeps(args, flags.name)
+		},
+		BindFlags: func(flagSet *pflag.FlagSet, flags *flags) {
+			flags.bindConfigData(flagSet)
+			flags.bindName(flagSet)
+			flags.bindProtocURL(flagSet)
+			flags.bindProtocBinPath(flagSet)
+			flags.bindProtocWKTPath(flagSet)
+		},
+	}
+
+	inspectPackageImportersCmdTemplate = &cmdTemplate{
+		Use:   "package-importers [dirOrFile]",
+		Short: "Print the given package importers. Be sure to set the required flag name.",
+		Args:  cobra.MaximumNArgs(1),
+		Run: func(runner exec.Runner, args []string, flags *flags) error {
+			return runner.InspectPackageImporters(args, flags.name)
+		},
+		BindFlags: func(flagSet *pflag.FlagSet, flags *flags) {
+			flags.bindConfigData(flagSet)
+			flags.bindName(flagSet)
+			flags.bindProtocURL(flagSet)
+			flags.bindProtocBinPath(flagSet)
+			flags.bindProtocWKTPath(flagSet)
+		},
+	}
+
 	configInitCmdTemplate = &cmdTemplate{
 		Use:   "init [dirPath]",
 		Short: "Generate an initial config file in the current or given directory.",
diff --git a/internal/exec/exec.go b/internal/exec/exec.go
index 7531bb5..0764043 100644
--- a/internal/exec/exec.go
+++ b/internal/exec/exec.go
@@ -62,6 +62,10 @@ type Runner interface {
 	JSONToBinary(args []string) error
 	All(args []string, disableFormat, disableLint, fix bool) error
 	GRPC(args, headers []string, address, method, data, callTimeout, connectTimeout, keepaliveTime string, stdin bool) error
+	InspectPackages(args []string) error
+	InspectPackage(args []string, name string) error
+	InspectPackageDeps(args []string, name string) error
+	InspectPackageImporters(args []string, name string) error
 }
 
 // RunnerOption is an option for a new Runner.
diff --git a/internal/exec/runner.go b/internal/exec/runner.go
index 73f7613..d4c4f51 100644
--- a/internal/exec/runner.go
+++ b/internal/exec/runner.go
@@ -647,6 +647,116 @@ func (r *runner) GRPC(args, headers []string, address, method, data, callTimeout
 	).Invoke(fileDescriptorSets, address, method, reader, r.output)
 }
 
+func (r *runner) InspectPackages(args []string) error {
+	packageSet, err := r.getPackageSet(args)
+	if err != nil {
+		return err
+	}
+	if packageSet == nil {
+		return nil
+	}
+	for _, pkg := range packageSet.Packages() {
+		if r.json {
+			data, err := json.MarshalIndent(pkg.ToExternalPackage(), "", "  ")
+			if err != nil {
+				return err
+			}
+			if err := r.println(string(data)); err != nil {
+				return err
+			}
+		} else {
+			if err := r.println(pkg.Name()); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func (r *runner) InspectPackage(args []string, name string) error {
+	if name == "" {
+		return newExitErrorf(255, "must set name")
+	}
+	packageSet, err := r.getPackageSet(args)
+	if err != nil {
+		return err
+	}
+	if packageSet == nil {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	pkg, ok := packageSet.GetPackage(name)
+	if !ok {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	data, err := json.MarshalIndent(pkg.ToExternalPackage(), "", "  ")
+	if err != nil {
+		return err
+	}
+	return r.println(string(data))
+}
+
+func (r *runner) InspectPackageDeps(args []string, name string) error {
+	if name == "" {
+		return newExitErrorf(255, "must set name")
+	}
+	packageSet, err := r.getPackageSet(args)
+	if err != nil {
+		return err
+	}
+	if packageSet == nil {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	pkg, ok := packageSet.GetPackage(name)
+	if !ok {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	for _, dep := range pkg.Deps() {
+		if err := r.println(dep); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *runner) InspectPackageImporters(args []string, name string) error {
+	if name == "" {
+		return newExitErrorf(255, "must set name")
+	}
+	packageSet, err := r.getPackageSet(args)
+	if err != nil {
+		return err
+	}
+	if packageSet == nil {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	pkg, ok := packageSet.GetPackage(name)
+	if !ok {
+		return fmt.Errorf("package not found: %s", name)
+	}
+	for _, importer := range pkg.Importers() {
+		if err := r.println(importer); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *runner) getPackageSet(args []string) (*extract.PackageSet, error) {
+	meta, err := r.getMeta(args, 1)
+	if err != nil {
+		return nil, err
+	}
+	r.printAffectedFiles(meta)
+	fileDescriptorSets, err := r.compile(false, true, false, meta)
+	if err != nil {
+		return nil, err
+	}
+	if len(fileDescriptorSets) == 0 {
+		return nil, fmt.Errorf("no FileDescriptorSets returned")
+	}
+	return r.newGetter().GetPackageSet(fileDescriptorSets)
+}
+
 func (r *runner) newDownloader(config settings.Config) (protoc.Downloader, error) {
 	downloaderOptions := []protoc.DownloaderOption{
 		protoc.DownloaderWithLogger(r.logger),
diff --git a/internal/extract/extract.go b/internal/extract/extract.go
index 184f89d..c15e370 100644
--- a/internal/extract/extract.go
+++ b/internal/extract/extract.go
@@ -24,10 +24,82 @@
 package extract
 
 import (
+	"sort"
+
 	"github.com/golang/protobuf/protoc-gen-go/descriptor"
 	"go.uber.org/zap"
 )
 
+// PackageSet is a set of extracted packages.
+type PackageSet struct {
+	// Map from fully-qualified name to package.
+	// Fully-qualified name does not include prefix '.'.
+	nameToPackage map[string]*Package
+}
+
+// Packages returns the list of packages sorted by name.
+func (p *PackageSet) Packages() []*Package {
+	packages := make([]*Package, 0, len(p.nameToPackage))
+	for _, pkg := range p.nameToPackage {
+		packages = append(packages, pkg)
+	}
+	sort.Stable(sortPackages(packages))
+	return packages
+}
+
+// GetPackage returns the package for the fully-qualified name without
+// the prefix '.', if it exists.
+func (p *PackageSet) GetPackage(name string) (*Package, bool) {
+	pkg, ok := p.nameToPackage[name]
+	return pkg, ok
+}
+
+// Package is an extracted package.
+type Package struct {
+	// Fully-qualified name does not include prefix '.'.
+	name string
+	// The fully-qualified names of the direct dependencies.
+	// For recursive dependencies, look these names up in the Packages struct.
+	deps []string
+	// The fully-qualified names of the importers.
+	// For recursive importers, look these names up in the Packages struct.
+	importers []string
+}
+
+// ExternalPackage is the external representation of a Package.
+type ExternalPackage struct {
+	Name      string   `json:"name,omitempty" yaml:"name,omitempty"`
+	Deps      []string `json:"deps,omitempty" yaml:"deps,omitempty"`
+	Importers []string `json:"importers,omitempty" yaml:"importers,omitempty"`
+}
+
+// Name returns the fully-qualified name.
+func (p *Package) Name() string {
+	return p.name
+}
+
+// Deps returns the dependency package names.
+func (p *Package) Deps() []string {
+	return p.deps
+}
+
+// Importers returns the importer package names.
+func (p *Package) Importers() []string {
+	return p.importers
+}
+
+// ToExternalPackage converts a Package to an ExternalPackage.
+func (p *Package) ToExternalPackage() *ExternalPackage {
+	if p == nil {
+		return nil
+	}
+	return &ExternalPackage{
+		Name:      p.Name(),
+		Deps:      copyStringSlice(p.Deps()),
+		Importers: copyStringSlice(p.Importers()),
+	}
+}
+
 // Field is an extracted field.
 type Field struct {
 	*descriptor.FieldDescriptorProto
@@ -61,6 +133,8 @@ type Service struct {
 // Paths can begin with ".".
 // The first FileDescriptorSet with a match will be returned.
 type Getter interface {
+	// Get the package set.
+	GetPackageSet(fileDescriptorSets []*descriptor.FileDescriptorSet) (*PackageSet, error)
 	// Get the field that matches the path.
 	// Return non-nil value, or error otherwise including if nothing found.
 	GetField(fileDescriptorSets []*descriptor.FileDescriptorSet, path string) (*Field, error)
@@ -88,3 +162,31 @@ func GetterWithLogger(logger *zap.Logger) GetterOption {
 func NewGetter(options ...GetterOption) Getter {
 	return newGetter(options...)
 }
+
+type sortPackages []*Package
+
+func (s sortPackages) Len() int          { return len(s) }
+func (s sortPackages) Swap(i int, j int) { s[i], s[j] = s[j], s[i] }
+func (s sortPackages) Less(i int, j int) bool {
+	if s[i] == nil && s[j] == nil {
+		return false
+	}
+	if s[i] == nil && s[j] != nil {
+		return true
+	}
+	if s[i] != nil && s[j] == nil {
+		return false
+	}
+	return s[i].name < s[j].name
+}
+
+func copyStringSlice(s []string) []string {
+	if s == nil {
+		return nil
+	}
+	c := make([]string, len(s))
+	for i, e := range s {
+		c[i] = e
+	}
+	return c
+}
diff --git a/internal/extract/getter.go b/internal/extract/getter.go
index 2667d07..893bd7d 100644
--- a/internal/extract/getter.go
+++ b/internal/extract/getter.go
@@ -21,10 +21,13 @@
 package extract
 
 import (
+	"bytes"
 	"fmt"
 	"strings"
 
+	"github.com/golang/protobuf/proto"
 	"github.com/golang/protobuf/protoc-gen-go/descriptor"
+	"github.com/uber/prototool/internal/strs"
 	"go.uber.org/zap"
 )
 
@@ -42,6 +45,44 @@ func newGetter(options ...GetterOption) *getter {
 	return getter
 }
 
+func (g *getter) GetPackageSet(fileDescriptorSets []*descriptor.FileDescriptorSet) (*PackageSet, error) {
+	packageNameToFileNameToFileDescriptorProto, err := getPackageNameToFileNameToFileDescriptorProto(fileDescriptorSets)
+	if err != nil {
+		return nil, err
+	}
+	fileNameToPackageName, err := getFileNameToPackageName(packageNameToFileNameToFileDescriptorProto)
+	if err != nil {
+		return nil, err
+	}
+	packageSet := &PackageSet{
+		nameToPackage: make(map[string]*Package),
+	}
+	for packageName := range packageNameToFileNameToFileDescriptorProto {
+		packageSet.nameToPackage[packageName] = &Package{
+			name: packageName,
+		}
+	}
+	for packageName, fileNameTofileDescriptorProto := range packageNameToFileNameToFileDescriptorProto {
+		for _, fileDescriptorProto := range fileNameTofileDescriptorProto {
+			for _, depFileName := range fileDescriptorProto.GetDependency() {
+				depPackageName, ok := fileNameToPackageName[depFileName]
+				if !ok {
+					return nil, fmt.Errorf("no package for dep %s", depFileName)
+				}
+				if depPackageName != packageName {
+					packageSet.nameToPackage[packageName].deps = append(packageSet.nameToPackage[packageName].deps, depPackageName)
+					packageSet.nameToPackage[depPackageName].importers = append(packageSet.nameToPackage[depPackageName].importers, packageName)
+				}
+			}
+		}
+	}
+	for _, pkg := range packageSet.nameToPackage {
+		pkg.deps = strs.DedupeSort(pkg.deps, nil)
+		pkg.importers = strs.DedupeSort(pkg.importers, nil)
+	}
+	return packageSet, nil
+}
+
 func (g *getter) GetField(fileDescriptorSets []*descriptor.FileDescriptorSet, path string) (*Field, error) {
 	if len(path) == 0 {
 		return nil, fmt.Errorf("empty path")
@@ -165,6 +206,64 @@ func (g *getter) GetService(fileDescriptorSets []*descriptor.FileDescriptorSet,
 	}, nil
 }
 
+func getPackageNameToFileNameToFileDescriptorProto(fileDescriptorSets []*descriptor.FileDescriptorSet) (map[string]map[string]*descriptor.FileDescriptorProto, error) {
+	packageNameToFileNameToFileDescriptorProto := make(map[string]map[string]*descriptor.FileDescriptorProto)
+	for _, fileDescriptorSet := range fileDescriptorSets {
+		for _, fileDescriptorProto := range fileDescriptorSet.GetFile() {
+			pkg := fileDescriptorProto.GetPackage()
+			if pkg == "" {
+				return nil, fmt.Errorf("no package on FileDescriptorProto")
+			}
+			if pkg[0] == '.' {
+				return nil, fmt.Errorf("malformed package fully-qualified name %s on FileDescriptorProto %+v", pkg, fileDescriptorProto)
+			}
+			existingMap, ok := packageNameToFileNameToFileDescriptorProto[pkg]
+			if !ok {
+				existingMap = make(map[string]*descriptor.FileDescriptorProto)
+				packageNameToFileNameToFileDescriptorProto[pkg] = existingMap
+			}
+			name := fileDescriptorProto.GetName()
+			existing, ok := existingMap[name]
+			if ok {
+				// we don't technically need to do this verification but
+				// we do for safety, if this ends up never erroring we
+				// can remove this if it becomes a performance issue
+				data, err := proto.Marshal(fileDescriptorProto)
+				if err != nil {
+					return nil, err
+				}
+				existingData, err := proto.Marshal(existing)
+				if err != nil {
+					return nil, err
+				}
+				if !bytes.Equal(data, existingData) {
+					return nil, fmt.Errorf("unequal FileDescriptorProtos for %s", name)
+				}
+			} else {
+				existingMap[name] = fileDescriptorProto
+			}
+		}
+	}
+	return packageNameToFileNameToFileDescriptorProto, nil
+}
+
+func getFileNameToPackageName(packageNameToFileNameToFileDescriptorProto map[string]map[string]*descriptor.FileDescriptorProto) (map[string]string, error) {
+	fileNameToPackageName := make(map[string]string)
+	for packageName, fileNameToFileDescriptorProto := range packageNameToFileNameToFileDescriptorProto {
+		for fileName := range fileNameToFileDescriptorProto {
+			existing, ok := fileNameToPackageName[fileName]
+			if ok {
+				if existing != packageName {
+					return nil, fmt.Errorf("mismatched packages names %s and %s for file %s", existing, packageName, fileName)
+				}
+			} else {
+				fileNameToPackageName[fileName] = packageName
+			}
+		}
+	}
+	return fileNameToPackageName, nil
+}
+
 // TODO: we don't actually do full path resolution per the descriptor.proto spec
 // https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/descriptor.proto#L185
 


Test patch:
diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go
index bd09365..5f21d64 100644
--- a/internal/cmd/cmd_test.go
+++ b/internal/cmd/cmd_test.go
@@ -643,6 +643,124 @@ func TestVersionJSON(t *testing.T) {
 	assertRegexp(t, 0, fmt.Sprintf(`(?s){.*"version":.*"%s",.*"default_protoc_version":.*"%s".*}`, vars.Version, vars.DefaultProtocVersion), "version", "--json")
 }
 
+func TestInspectPackages(t *testing.T) {
+	assertExact(
+		t,
+		0,
+		`bar
+foo
+google.protobuf`,
+		"inspect", "packages", "testdata/foo",
+	)
+}
+
+func TestInspectPackagesJSON(t *testing.T) {
+	assertExact(
+		t,
+		0,
+		`{
+  "name": "bar",
+  "importers": [
+    "foo"
+  ]
+}
+{
+  "name": "foo",
+  "deps": [
+    "bar",
+    "google.protobuf"
+  ]
+}
+{
+  "name": "google.protobuf",
+  "importers": [
+    "foo"
+  ]
+}`,
+		"inspect", "packages", "testdata/foo", "--json",
+	)
+}
+
+func TestInspectPackage(t *testing.T) {
+	assertExact(
+		t,
+		0,
+		`{
+  "name": "foo",
+  "deps": [
+    "bar",
+    "google.protobuf"
+  ]
+}`,
+		"inspect", "package", "testdata/foo", "--name", "foo",
+	)
+	assertExact(
+		t,
+		0,
+		`{
+  "name": "bar",
+  "importers": [
+    "foo"
+  ]
+}`,
+		"inspect", "package", "testdata/foo", "--name", "bar",
+	)
+	assertExact(
+		t,
+		0,
+		`{
+  "name": "google.protobuf",
+  "importers": [
+    "foo"
+  ]
+}`,
+		"inspect", "package", "testdata/foo", "--name", "google.protobuf",
+	)
+}
+
+func TestInspectPackageDeps(t *testing.T) {
+	assertExact(
+		t,
+		0,
+		`bar
+google.protobuf`,
+		"inspect", "package-deps", "testdata/foo", "--name", "foo",
+	)
+	assertExact(
+		t,
+		0,
+		``,
+		"inspect", "package-deps", "testdata/foo", "--name", "bar",
+	)
+	assertExact(
+		t,
+		0,
+		``,
+		"inspect", "package-deps", "testdata/foo", "--name", "google.protobuf",
+	)
+}
+
+func TestInspectPackageImporters(t *testing.T) {
+	assertExact(
+		t,
+		0,
+		``,
+		"inspect", "package-importers", "testdata/foo", "--name", "foo",
+	)
+	assertExact(
+		t,
+		0,
+		`foo`,
+		"inspect", "package-importers", "testdata/foo", "--name", "bar",
+	)
+	assertExact(
+		t,
+		0,
+		`foo`,
+		"inspect", "package-importers", "testdata/foo", "--name", "google.protobuf",
+	)
+}
+
 func TestDescriptorProto(t *testing.T) {
 	assertExact(
 		t,