Repository: apigee/registry
Pull request #904 (2023-01-05 18:33:40)

registry check
Addresses #599. 

This PR is mostly about putting the framework in place for the registry checker. It includes one "example" rule for reference, but this may change and proper rule sets will be added later. As such, there is much work to come in follow-on PRs, but I'd like to get this reviewed as it is already a large change that sets the stage for the future and I'd like to see agreement that this is an acceptible implementation approach before we continue work.

codecov[bot]: # [Codecov](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) Report
> Merging [#904](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (fb62723) into [main](https://codecov.io/gh/apigee/registry/commit/fd78bbbd1e2bb8af65806d659810828362bcb6b6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (fd78bbb) will **increase** coverage by `0.90%`.
> The diff coverage is `79.36%`.

> :exclamation: Current head fb62723 differs from pull request most recent head 0bcae01. Consider uploading reports for the commit 0bcae01 to get more accurate results

```diff
@@            Coverage Diff             @@
##             main     #904      +/-   ##
==========================================
+ Coverage   60.57%   61.48%   +0.90%     
==========================================
  Files          99      112      +13     
  Lines        9246     9716     +470     
==========================================
+ Hits         5601     5974     +373     
- Misses       3049     3129      +80     
- Partials      596      613      +17     
```


| [Impacted Files](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) | Coverage Δ | |
|---|---|---|
| [server/registry/names/names.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-c2VydmVyL3JlZ2lzdHJ5L25hbWVzL25hbWVzLmdv) | `0.00% <0.00%> (ø)` | |
| [cmd/registry/cmd/check/check.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9jaGVjay5nbw==) | `48.07% <48.07%> (ø)` | |
| [cmd/registry/cmd/check/lint/checker.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L2NoZWNrZXIuZ28=) | `73.25% <73.25%> (ø)` | |
| [cmd/registry/cmd/check/lint/rule.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3J1bGUuZ28=) | `95.38% <95.38%> (ø)` | |
| [cmd/registry/cmd/check/lint/rule\_groups.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3J1bGVfZ3JvdXBzLmdv) | `96.77% <96.77%> (ø)` | |
| [cmd/registry/cmd/check/lint/config.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L2NvbmZpZy5nbw==) | `100.00% <100.00%> (ø)` | |
| [cmd/registry/cmd/check/lint/problem.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3Byb2JsZW0uZ28=) | `100.00% <100.00%> (ø)` | |
| [cmd/registry/cmd/check/lint/response.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3Jlc3BvbnNlLmdv) | `100.00% <100.00%> (ø)` | |
| [cmd/registry/cmd/check/lint/rule\_name.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3J1bGVfbmFtZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [cmd/registry/cmd/check/lint/rule\_registry.go](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9jaGVjay9saW50L3J1bGVfcmVnaXN0cnkuZ28=) | `100.00% <100.00%> (ø)` | |
| ... and [3 more](https://codecov.io/gh/apigee/registry/pull/904?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee)

theganyo: > I didn't try to diagnose the data race that's breaking the tests but would prefer that we wait to merge after that's fixed.

Fixed

Patch:
diff --git a/cmd/registry/cmd/check/check.go b/cmd/registry/cmd/check/check.go
new file mode 100644
index 0000000..a805ff0
--- /dev/null
+++ b/cmd/registry/cmd/check/check.go
@@ -0,0 +1,100 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package check
+
+import (
+	"log"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules"
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/server/registry/names"
+	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v3"
+)
+
+var (
+	globalRules   = lint.NewRuleRegistry()
+	globalConfigs = defaultConfigs()
+)
+
+func init() {
+	if err := rules.Add(globalRules); err != nil {
+		log.Fatalf("error when registering rules: %v", err)
+	}
+}
+
+// Enable all rules by default.
+func defaultConfigs() lint.Configs {
+	return lint.Configs{}
+}
+
+func Command() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "check [pattern]",
+		Short: "Check entities in the registry",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			c, err := connection.ActiveConfig()
+			if err != nil {
+				return err
+			}
+
+			root, err := names.Parse(c.FQName(args[0]))
+			if err != nil {
+				return err
+			}
+
+			filter, err := cmd.Flags().GetString("filter")
+			if err != nil {
+				return err
+			}
+
+			jobs, err := cmd.Flags().GetInt("jobs")
+			if err != nil {
+				return err
+			}
+
+			client, err := connection.NewRegistryClientWithSettings(ctx, c)
+			if err != nil {
+				return err
+			}
+			adminClient, err := connection.NewAdminClientWithSettings(ctx, c)
+			if err != nil {
+				return err
+			}
+
+			linter := lint.New(globalRules, globalConfigs)
+			response, err := linter.Check(ctx, adminClient, client, root, filter, jobs)
+			if err != nil {
+				return err
+			}
+
+			serialized, err := yaml.Marshal(response)
+			if err != nil {
+				return err
+			}
+
+			_, err = cmd.OutOrStdout().Write(serialized)
+			return err
+		},
+	}
+
+	cmd.PersistentFlags().String("filter", "", "Filter selected resources")
+	cmd.PersistentFlags().Int("jobs", 10, "Number of actions to perform concurrently")
+
+	return cmd
+}
diff --git a/cmd/registry/cmd/check/lint/checker.go b/cmd/registry/cmd/check/lint/checker.go
new file mode 100644
index 0000000..87a6605
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/checker.go
@@ -0,0 +1,169 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/tree"
+	"github.com/apigee/registry/cmd/registry/core"
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry/names"
+)
+
+type Resource interface {
+	GetName() string
+}
+
+// Checker checks API files and returns a list of detected problems.
+type Checker struct {
+	rules   RuleRegistry
+	configs Configs
+}
+
+// New creates and returns a checker with the given rules and configs.
+func New(rules RuleRegistry, configs Configs) *Checker {
+	l := &Checker{
+		rules:   rules,
+		configs: configs,
+	}
+	return l
+}
+
+func (l *Checker) Check(ctx context.Context, admin connection.AdminClient, client connection.RegistryClient, root names.Name, filter string, jobs int) (response *Response, err error) {
+	response = &Response{
+		RunTime:  time.Now(),
+		Problems: make([]Problem, 0),
+	}
+
+	taskQueue, wait := core.WorkerPool(ctx, jobs)
+	defer func() {
+		wait()
+		if response.Error != nil { // from a panic
+			err = response.Error
+		}
+	}()
+
+	handler := &listHandler{
+		taskQueue: taskQueue,
+		newTask: func(r Resource) *checkTask {
+			return &checkTask{l, response, r}
+		},
+	}
+	err = tree.ListSubresources(ctx, admin, client, root, filter, true, handler)
+	return response, err
+}
+
+type checkTask struct {
+	checker  *Checker
+	response *Response
+	resource Resource
+}
+
+func (t *checkTask) String() string {
+	return "check " + t.resource.GetName()
+}
+
+func (t *checkTask) Run(ctx context.Context) error {
+	var problems []Problem
+	var errMessages []string
+	for name, rule := range t.checker.rules {
+		if t.checker.configs.IsRuleEnabled(string(name), t.resource.GetName()) {
+			if probs, err := t.runAndRecoverFromPanics(rule, t.resource); err == nil {
+				for _, p := range probs {
+					if p.RuleID == "" {
+						p.RuleID = rule.GetName()
+					}
+					if p.Location == "" {
+						p.Location = t.resource.GetName()
+					}
+					problems = append(problems, p)
+				}
+			} else {
+				errMessages = append(errMessages, err.Error())
+			}
+		}
+	}
+	var err error
+	if len(errMessages) != 0 {
+		err = errors.New(strings.Join(errMessages, "; "))
+	}
+
+	t.response.append(t.resource, problems, err)
+	return nil
+}
+
+func (c *checkTask) runAndRecoverFromPanics(rule Rule, resource Resource) (probs []Problem, err error) {
+	defer func() {
+		if r := recover(); r != nil {
+			if rerr, ok := r.(error); ok {
+				err = rerr
+			} else {
+				err = fmt.Errorf("panic occurred during rule execution: %v", r)
+			}
+		}
+	}()
+
+	return rule.Apply(resource), nil
+}
+
+type listHandler struct {
+	taskQueue chan<- core.Task
+	newTask   func(r Resource) *checkTask
+}
+
+func (c *listHandler) queueTask(r Resource) error {
+	c.taskQueue <- c.newTask(r)
+	return nil
+}
+
+func (c *listHandler) ProjectHandler() core.ProjectHandler {
+	return func(p *rpc.Project) error {
+		return c.queueTask(p)
+	}
+}
+
+func (c *listHandler) ApiHandler() core.ApiHandler {
+	return func(a *rpc.Api) error {
+		return c.queueTask(a)
+	}
+}
+func (c *listHandler) DeploymentHandler() core.DeploymentHandler {
+	return func(a *rpc.ApiDeployment) error {
+		return c.queueTask(a)
+	}
+}
+func (c *listHandler) VersionHandler() core.VersionHandler {
+	return func(a *rpc.ApiVersion) error {
+		return c.queueTask(a)
+	}
+}
+
+func (c *listHandler) SpecHandler() core.SpecHandler {
+	return func(a *rpc.ApiSpec) error {
+		return c.queueTask(a)
+	}
+}
+
+func (c *listHandler) ArtifactHandler() core.ArtifactHandler {
+	return func(a *rpc.Artifact) error {
+		return c.queueTask(a)
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/config.go b/cmd/registry/cmd/check/lint/config.go
new file mode 100644
index 0000000..87b729a
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/config.go
@@ -0,0 +1,143 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/bmatcuk/doublestar/v4"
+	"gopkg.in/yaml.v2"
+)
+
+var defaultDisabledRules = []string{}
+
+// Configs determine if a rule is enabled or not.
+type Configs []Config
+
+// Config stores rule configurations for certain resource names
+// such that the resource name must match any of the included paths
+// but none of the excluded ones.
+type Config struct {
+	IncludedPaths []string `json:"included_paths" yaml:"included_paths"`
+	ExcludedPaths []string `json:"excluded_paths" yaml:"excluded_paths"`
+	EnabledRules  []string `json:"enabled_rules" yaml:"enabled_rules"`
+	DisabledRules []string `json:"disabled_rules" yaml:"disabled_rules"`
+}
+
+// ReadConfigsFromFile reads Configs from a file.
+// It supports JSON(.json) and YAML(.yaml or .yml) files.
+func ReadConfigsFromFile(path string) (Configs, error) {
+	var parse func(io.Reader) (Configs, error)
+	switch filepath.Ext(path) {
+	case ".json":
+		parse = ReadConfigsJSON
+	case ".yaml", ".yml":
+		parse = ReadConfigsYAML
+	}
+	if parse == nil {
+		return nil, fmt.Errorf("reading Configs: unsupported format `%q` with file path `%q`", filepath.Ext(path), path)
+	}
+
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("readConfig: %s", err.Error())
+	}
+	defer f.Close()
+
+	return parse(f)
+}
+
+// ReadConfigsJSON reads Configs from a JSON file.
+func ReadConfigsJSON(f io.Reader) (Configs, error) {
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil, err
+	}
+	var c Configs
+	if err := json.Unmarshal(b, &c); err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+// ReadConfigsYAML reads Configs from a YAML(.yml or .yaml) file.
+func ReadConfigsYAML(f io.Reader) (Configs, error) {
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil, err
+	}
+	var c Configs
+	if err := yaml.Unmarshal(b, &c); err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+// IsRuleEnabled returns true if a rule is enabled by the configs.
+func (configs Configs) IsRuleEnabled(rule string, resourceName string) bool {
+	// Enabled by default if the rule does not belong to one of the default
+	// disabled groups. Otherwise, needs to be explicitly enabled.
+	enabled := !matchRule(rule, defaultDisabledRules...)
+	for _, c := range configs {
+		if c.matchPath(resourceName) {
+			if matchRule(rule, c.DisabledRules...) {
+				enabled = false
+			}
+			if matchRule(rule, c.EnabledRules...) {
+				enabled = true
+			}
+		}
+	}
+
+	return enabled
+}
+
+func (c Config) matchPath(path string) bool {
+	if matchPath(path, c.ExcludedPaths...) {
+		return false
+	}
+	return len(c.IncludedPaths) == 0 || matchPath(path, c.IncludedPaths...)
+}
+
+func matchPath(path string, pathPatterns ...string) bool {
+	for _, pattern := range pathPatterns {
+		if matched, _ := doublestar.Match(pattern, path); matched {
+			return true
+		}
+	}
+	return false
+}
+
+func matchRule(rule string, rulePrefixes ...string) bool {
+	rule = strings.ToLower(rule)
+	for _, prefix := range rulePrefixes {
+		prefix = strings.ToLower(prefix)
+		prefix = strings.TrimSuffix(prefix, nameSeparator) // "core::" -> "core"
+		prefix = strings.TrimPrefix(prefix, nameSeparator) // "::http-body" -> "http-body"
+		if prefix == "all" ||
+			prefix == rule ||
+			strings.HasPrefix(rule, prefix+nameSeparator) || // e.g., "core" matches "core::http-body", but not "core-rules::http-body"
+			strings.HasSuffix(rule, nameSeparator+prefix) || // e.g., "http-body" matches "core::http-body", but not "core::google-http-body"
+			strings.Contains(rule, nameSeparator+prefix+nameSeparator) { // e.g., "http-body" matches "core::http-body::post", but not "core::google-http-body::post"
+			return true
+		}
+	}
+	return false
+}
diff --git a/cmd/registry/cmd/check/lint/problem.go b/cmd/registry/cmd/check/lint/problem.go
new file mode 100644
index 0000000..f89e871
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/problem.go
@@ -0,0 +1,86 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"encoding/json"
+)
+
+type Severity int32
+
+const (
+	INFO Severity = iota
+	WARNING
+	ERROR
+)
+
+// Problem contains information about a result produced by an API Linter.
+//
+// All rules return []Problem. Most lint rules return 0 or 1 problems, but
+// occasionally there are rules that may return more than one.
+type Problem struct {
+	// Message provides a short description of the problem.
+	// This should be no more than a single sentence.
+	Message string
+
+	// Suggestion provides a suggested fix, if applicable.
+	Suggestion string
+
+	// Location provides the location of the problem.
+	// If for a Resource, it is the Resource name.
+	// If for a field, this is the Resource name + "::" + field name.
+	// The linter sets this automatically.
+	Location string
+
+	// RuleID provides the ID of the rule that this problem belongs to.
+	// The linter sets this automatically.
+	RuleID RuleName
+
+	// Severity provides information on the criticality of the Problem.
+	Severity Severity
+}
+
+// MarshalJSON defines how to represent a Problem in JSON.
+func (p Problem) MarshalJSON() ([]byte, error) {
+	return json.Marshal(p.marshal())
+}
+
+// MarshalYAML defines how to represent a Problem in YAML.
+func (p Problem) MarshalYAML() (interface{}, error) {
+	return p.marshal(), nil
+}
+
+// Marshal defines how to represent a serialized Problem.
+func (p Problem) marshal() interface{} {
+	// Return a marshal-able structure.
+	return struct {
+		Message    string   `json:"message" yaml:"message"`
+		Suggestion string   `json:"suggestion,omitempty" yaml:"suggestion,omitempty"`
+		Location   string   `json:"location" yaml:"location"`
+		RuleID     RuleName `json:"rule_id" yaml:"rule_id"`
+		RuleDocURI string   `json:"rule_doc_uri" yaml:"rule_doc_uri"`
+	}{
+		p.Message,
+		p.Suggestion,
+		p.Location,
+		p.RuleID,
+		p.GetRuleURI(),
+	}
+}
+
+// GetRuleURI returns a URI to learn more about the problem.
+func (p Problem) GetRuleURI() string {
+	return getRuleURL(string(p.RuleID), ruleURLMappings)
+}
diff --git a/cmd/registry/cmd/check/lint/response.go b/cmd/registry/cmd/check/lint/response.go
new file mode 100644
index 0000000..432b037
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/response.go
@@ -0,0 +1,37 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"sync"
+	"time"
+)
+
+// Response collects the results of running the Rules.
+type Response struct {
+	sync.Mutex
+	RunTime  time.Time `json:"time" yaml:"time"`
+	Problems []Problem `json:"problems" yaml:"problems"`
+	Error    error     `json:"error,omitempty" yaml:"error,omitempty"` // populated if panic
+}
+
+func (r *Response) append(res Resource, probs []Problem, err error) {
+	r.Lock()
+	defer r.Unlock()
+	r.Problems = append(r.Problems, probs...)
+	if err != nil {
+		r.Error = err
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/rule.go b/cmd/registry/cmd/check/lint/rule.go
new file mode 100644
index 0000000..7a9985d
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule.go
@@ -0,0 +1,231 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"reflect"
+
+	"github.com/apigee/registry/rpc"
+)
+
+// Rule defines a rule for checking a Resource.
+// Anything that satisfies this interface can be used as a rule,
+// but most rule authors will want to use the implementations provided.
+type Rule interface {
+	// GetName returns the name of the rule.
+	GetName() RuleName
+
+	// Apply accepts a resource and checks it,
+	// returning a slice of Problems it finds.
+	Apply(resource Resource) []Problem
+}
+
+type ProjectRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.Project) bool
+
+	// ApplyToProject accepts a Project and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToProject func(p *rpc.Project) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ProjectRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToProject if the Resource is a Project.
+func (r *ProjectRule) Apply(res Resource) (problems []Problem) {
+	if p, ok := res.(*rpc.Project); ok {
+		if r.OnlyIf == nil || r.OnlyIf(p) {
+			problems = r.ApplyToProject(p)
+		}
+	}
+
+	return problems
+}
+
+type ArtifactRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.Artifact) bool
+
+	// ApplyToArtifact accepts an Artifact and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToArtifact func(p *rpc.Artifact) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ArtifactRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToArtifact if the Resource is an Artifact.
+func (r *ArtifactRule) Apply(res Resource) (problems []Problem) {
+	if a, ok := res.(*rpc.Artifact); ok {
+		if r.OnlyIf == nil || r.OnlyIf(a) {
+			problems = r.ApplyToArtifact(a)
+		}
+	}
+	return problems
+}
+
+type ApiRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.Api) bool
+
+	// ApplyToApi accepts an Api and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToApi func(p *rpc.Api) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ApiRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToApi if the Resource is an Api.
+func (r *ApiRule) Apply(res Resource) (problems []Problem) {
+	if a, ok := res.(*rpc.Api); ok {
+		if r.OnlyIf == nil || r.OnlyIf(a) {
+			problems = r.ApplyToApi(a)
+		}
+	}
+	return problems
+}
+
+type ApiDeploymentRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.ApiDeployment) bool
+
+	// ApplyToApiDeployment accepts an ApiDeployment and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToApiDeployment func(p *rpc.ApiDeployment) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ApiDeploymentRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToApiDeployment if the Resource is an ApiDeployment.
+func (r *ApiDeploymentRule) Apply(res Resource) (problems []Problem) {
+	if a, ok := res.(*rpc.ApiDeployment); ok {
+		if r.OnlyIf == nil || r.OnlyIf(a) {
+			problems = r.ApplyToApiDeployment(a)
+		}
+	}
+	return problems
+}
+
+type ApiVersionRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.ApiVersion) bool
+
+	// ApplyToApiVersion accepts a Version and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToApiVersion func(p *rpc.ApiVersion) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ApiVersionRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToVersion if the Resource is an ApiVersion.
+func (r *ApiVersionRule) Apply(res Resource) (problems []Problem) {
+	if a, ok := res.(*rpc.ApiVersion); ok {
+		if r.OnlyIf == nil || r.OnlyIf(a) {
+			problems = r.ApplyToApiVersion(a)
+		}
+	}
+	return problems
+}
+
+type ApiSpecRule struct {
+	Name RuleName
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(p *rpc.ApiSpec) bool
+
+	// ApiSpecRule accepts an ApiSpec and checks it,
+	// returning a slice of Problems it finds.
+	ApplyToApiSpec func(p *rpc.ApiSpec) []Problem
+}
+
+// GetName returns the name of the rule.
+func (r *ApiSpecRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply calls ApplyToApiSpec if the Resource is an ApiSpec.
+func (r *ApiSpecRule) Apply(res Resource) (problems []Problem) {
+	if a, ok := res.(*rpc.ApiSpec); ok {
+		if r.OnlyIf == nil || r.OnlyIf(a) {
+			problems = r.ApplyToApiSpec(a)
+		}
+	}
+	return problems
+}
+
+// FieldRule defines a rule that is run on each field within a file.
+type FieldRule struct {
+	Name RuleName
+
+	// ApplyToField accepts a Field name and value and checks it, returning a slice of
+	// Problems it finds.
+	ApplyToField func(resource Resource, field string, value interface{}) []Problem
+
+	// OnlyIf determines whether this rule is applicable.
+	OnlyIf func(resource Resource, field string) bool
+}
+
+// GetName returns the name of the rule.
+func (r *FieldRule) GetName() RuleName {
+	return r.Name
+}
+
+// Apply visits every field in the passed resource and runs `ApplyToField`.
+//
+// If an `OnlyIf` function is provided on the rule, it is run against each
+// field, and if it returns false, the `ApplyToField` function is not called.
+func (r *FieldRule) Apply(res Resource) (problems []Problem) {
+	t := reflect.TypeOf(res)
+	v := reflect.ValueOf(res)
+	in := reflect.Indirect(v)
+	for i := 0; i < in.NumField(); i++ {
+		name := t.Elem().Field(i).Name
+		if r.OnlyIf == nil || r.OnlyIf(res, name) {
+			value := v.Elem().Field(i)
+			probs := r.ApplyToField(res, name, value)
+			for i := range probs {
+				if probs[i].Location == "" {
+					probs[i].Location = res.GetName() + "::" + name
+				}
+			}
+			problems = append(problems, probs...)
+		}
+	}
+	return problems
+}
diff --git a/cmd/registry/cmd/check/lint/rule_groups.go b/cmd/registry/cmd/check/lint/rule_groups.go
new file mode 100644
index 0000000..0e1a50a
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_groups.go
@@ -0,0 +1,60 @@
+package lint
+
+import "fmt"
+
+// A list of functions, each of which returns the group name for the given rule
+// number and if no group is found, returns an empty string.
+// List will be evaluated in the FILO order.
+// TODO: complete rule groups
+var ruleGroup = []func(int) string{
+	registryGroup,
+	hubGroup,
+	controllerGroup,
+	scoreGroup,
+	styleGroup,
+}
+
+func registryGroup(ruleNum int) string {
+	if ruleNum > 0 && ruleNum < 1000 {
+		return "registry"
+	}
+	return ""
+}
+
+func hubGroup(ruleNum int) string {
+	if ruleNum >= 1000 && ruleNum < 1100 {
+		return "hub"
+	}
+	return ""
+}
+
+func controllerGroup(ruleNum int) string {
+	if ruleNum >= 1100 && ruleNum < 1200 {
+		return "controller"
+	}
+	return ""
+}
+
+func scoreGroup(ruleNum int) string {
+	if ruleNum >= 1200 && ruleNum < 1300 {
+		return "score"
+	}
+	return ""
+}
+func styleGroup(ruleNum int) string {
+	if ruleNum >= 1300 && ruleNum < 1400 {
+		return "style"
+	}
+	return ""
+}
+
+// getRuleGroup takes an rule number and returns the appropriate group.
+// It panics if no group is found.
+func getRuleGroup(ruleNum int, groups []func(int) string) string {
+	for i := len(groups) - 1; i >= 0; i-- {
+		if group := groups[i](ruleNum); group != "" {
+			return group
+		}
+	}
+	panic(fmt.Sprintf("Invalid rule number %d: no available group.", ruleNum))
+}
diff --git a/cmd/registry/cmd/check/lint/rule_name.go b/cmd/registry/cmd/check/lint/rule_name.go
new file mode 100644
index 0000000..f75af1b
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_name.go
@@ -0,0 +1,61 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// RuleName is an identifier for a rule. Allowed characters include a-z, 0-9, -.
+//
+// The namespace separator :: is allowed between RuleName segments
+// (for example, my-namespace::my-rule).
+type RuleName string
+
+const nameSeparator string = "::"
+
+var ruleNameValidator = regexp.MustCompile("^([a-z0-9][a-z0-9-]*(::[a-z0-9][a-z0-9-]*)?)+$")
+
+// NewRuleName creates a RuleName from an rule number and a unique name within
+// that rule.
+func NewRuleName(ruleNum int, name string) RuleName {
+	return RuleName(strings.Join([]string{
+		getRuleGroup(ruleNum, ruleGroup),
+		fmt.Sprintf("%04d", ruleNum),
+		name,
+	}, nameSeparator))
+}
+
+// IsValid checks if a RuleName is syntactically valid.
+func (r RuleName) IsValid() bool {
+	return r != "" && ruleNameValidator.Match([]byte(r))
+}
+
+// HasPrefix returns true if r contains prefix as a namespace. prefix parameters can be "::" delimited
+// or specified as independent parameters.
+// For example:
+//
+// r := NewRuleName("foo", "bar", "baz")   // string(r) == "foo::bar::baz"
+//
+// r.HasPrefix("foo::bar")          == true
+// r.HasPrefix("foo", "bar")        == true
+// r.HasPrefix("foo", "bar", "baz") == true   // matches the entire string
+// r.HasPrefix("foo", "ba")         == false  // prefix must end on a delimiter
+func (r RuleName) HasPrefix(prefix ...string) bool {
+	s := strings.Join(prefix, nameSeparator)
+	return string(r) == s || strings.HasPrefix(string(r), s+nameSeparator)
+}
diff --git a/cmd/registry/cmd/check/lint/rule_registry.go b/cmd/registry/cmd/check/lint/rule_registry.go
new file mode 100644
index 0000000..88976f5
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_registry.go
@@ -0,0 +1,56 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"errors"
+	"fmt"
+)
+
+// RuleRegistry is a registry for registering and looking up rules.
+type RuleRegistry map[RuleName]Rule
+
+var (
+	errInvalidRuleName    = errors.New("not a valid rule name")
+	errInvalidRuleGroup   = errors.New("invalid rule group")
+	errDuplicatedRuleName = errors.New("duplicate rule name")
+)
+
+// NewRuleRegistry creates a new rule registry.
+func NewRuleRegistry() RuleRegistry {
+	return make(RuleRegistry)
+}
+
+// Register registers the list of rules of the same ruleNum.
+// Return an error if any of the rules is found duplicate in the registry.
+func (r RuleRegistry) Register(ruleNum int, rules ...Rule) error {
+	rulePrefix := getRuleGroup(ruleNum, ruleGroup) + nameSeparator + fmt.Sprintf("%04d", ruleNum)
+	for _, rl := range rules {
+		if !rl.GetName().IsValid() {
+			return errInvalidRuleName
+		}
+
+		if !rl.GetName().HasPrefix(rulePrefix) {
+			return errInvalidRuleGroup
+		}
+
+		if _, found := r[rl.GetName()]; found {
+			return errDuplicatedRuleName
+		}
+
+		r[rl.GetName()] = rl
+	}
+	return nil
+}
diff --git a/cmd/registry/cmd/check/lint/rule_urls.go b/cmd/registry/cmd/check/lint/rule_urls.go
new file mode 100644
index 0000000..5edcdcd
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_urls.go
@@ -0,0 +1,40 @@
+package lint
+
+import "strings"
+
+// TODO: determine base URL
+const baseURL = "https://github.com/apigee/registry/wiki/"
+
+// A list of mapping functions, each of which returns the rule URL for
+// the given rule name, and if not found, return an empty string.
+// TODO: complete rule mappings
+var ruleURLMappings = []func(string) string{
+	coreRuleURL,
+	hubRuleUrl,
+}
+
+func coreRuleURL(ruleName string) string {
+	return groupUrl(ruleName, "core")
+}
+
+func hubRuleUrl(ruleName string) string {
+	return groupUrl(ruleName, "hub")
+}
+
+func groupUrl(ruleName, groupName string) string {
+	nameParts := strings.Split(ruleName, "::") // e.g., registry::0122::camel-case-uris -> ["registry", "0122", "camel-case-uris"]
+	if len(nameParts) == 0 || nameParts[0] != groupName {
+		return ""
+	}
+	path := strings.TrimPrefix(strings.Join(nameParts[1:], "/"), "0")
+	return baseURL + path
+}
+
+func getRuleURL(ruleName string, nameURLMappings []func(string) string) string {
+	for i := len(nameURLMappings) - 1; i >= 0; i-- {
+		if url := nameURLMappings[i](ruleName); url != "" {
+			return url
+		}
+	}
+	return ""
+}
diff --git a/cmd/registry/cmd/check/lint/task.go b/cmd/registry/cmd/check/lint/task.go
new file mode 100644
index 0000000..f443bee
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/task.go
@@ -0,0 +1,15 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
diff --git a/cmd/registry/cmd/check/rules/rule0001/mime_type_contents.go b/cmd/registry/cmd/check/rules/rule0001/mime_type_contents.go
new file mode 100644
index 0000000..e82f342
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule0001/mime_type_contents.go
@@ -0,0 +1,74 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rule0001
+
+import (
+	"fmt"
+	"mime"
+	"net/http"
+	"strings"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/rpc"
+)
+
+var mimeTypeContents = &lint.FieldRule{
+	Name: lint.NewRuleName(1, "mime-type-contents"),
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == "MimeType"
+	},
+	ApplyToField: func(resource lint.Resource, field string, value interface{}) []lint.Problem {
+		var declared string
+		var contents []byte
+		switch t := resource.(type) {
+		case *rpc.ApiSpec:
+			declared = t.GetMimeType()
+			contents = t.GetContents()
+		case *rpc.Artifact:
+			declared = t.GetMimeType()
+			contents = t.GetContents()
+		}
+		if len(contents) == 0 {
+			return nil
+		}
+		detected := http.DetectContentType(contents)
+
+		if strings.TrimSpace(declared) == "" {
+			return []lint.Problem{{
+				Severity:   lint.ERROR,
+				Message:    "Empty mime_type.",
+				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
+			}}
+		}
+
+		declaredType, _, err := mime.ParseMediaType(declared)
+		if err != nil {
+			return []lint.Problem{{
+				Severity: lint.ERROR,
+				Message:  fmt.Sprintf("Unable to parse mime_type %q: %s.", declared, err),
+			}}
+		}
+
+		detectedType, _, _ := mime.ParseMediaType(detected)
+		if declaredType != detectedType {
+			return []lint.Problem{{
+				Severity:   lint.WARNING,
+				Message:    fmt.Sprintf("Unexpected mime_type %q for contents.", declared),
+				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
+			}}
+		}
+		return nil
+	},
+}
diff --git a/cmd/registry/cmd/check/rules/rule0001/rule0001.go b/cmd/registry/cmd/check/rules/rule0001/rule0001.go
new file mode 100644
index 0000000..4a12613
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule0001/rule0001.go
@@ -0,0 +1,26 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rule0001
+
+import "github.com/apigee/registry/cmd/registry/cmd/check/lint"
+
+// AddRules accepts a register function and registers each of
+// this rules' checks to the RuleRegistry.
+func AddRules(r lint.RuleRegistry) error {
+	return r.Register(
+		0001,
+		mimeTypeContents,
+	)
+}
diff --git a/cmd/registry/cmd/check/rules/rules.go b/cmd/registry/cmd/check/rules/rules.go
new file mode 100644
index 0000000..1b5ac2d
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rules.go
@@ -0,0 +1,57 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package rules contains implementations of checker rules.
+//
+// Rules are sorted into groups. Every rule represented in code here must
+// be documented in English in a corresponding registry rule. Conversely,
+// anything mandated in such a rule should have a rule here if it is feasible
+// to enforce in code (sometimes it is infeasible, however).
+//
+// A rule is technically anything with a `GetName()` and `Apply(Resource)`
+// but most rule authors will want to use the rule structs provided in the
+// lint package (`lint.ProjectRule`, lint.FieldRule`, and so on). These run
+// against each applicable registry resource type or field. They also have
+// an `OnlyIf` property that can be used to run against a subset of resources.
+//
+// Once a rule is written, it needs to be registered. This involves adding
+// the rule to the `AddRules` method for the appropriate group.
+// If this is the first rule for a new package, then the `rules.go` init() function
+// must also be updated to run the `Add` function for the new package.
+package rules
+
+import (
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule0001"
+)
+
+type addRulesFuncType func(lint.RuleRegistry) error
+
+var addRulesFuncs = []addRulesFuncType{
+	rule0001.AddRules,
+}
+
+// Add all rules to the given registry.
+func Add(r lint.RuleRegistry) error {
+	return addRules(r, addRulesFuncs)
+}
+
+func addRules(r lint.RuleRegistry, addRulesFuncs []addRulesFuncType) error {
+	for _, addRules := range addRulesFuncs {
+		if err := addRules(r); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/registry/cmd/check/tree/tree.go b/cmd/registry/cmd/check/tree/tree.go
new file mode 100644
index 0000000..79fefe8
--- /dev/null
+++ b/cmd/registry/cmd/check/tree/tree.go
@@ -0,0 +1,183 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package tree
+
+import (
+	"context"
+
+	"github.com/apigee/registry/cmd/registry/core"
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/server/registry/names"
+)
+
+// HandlerSet should return an appropriate Handler for each
+// rpc.* type.
+type HandlerSet interface {
+	ProjectHandler() core.ProjectHandler
+	ApiHandler() core.ApiHandler
+	DeploymentHandler() core.DeploymentHandler
+	VersionHandler() core.VersionHandler
+	SpecHandler() core.SpecHandler
+	ArtifactHandler() core.ArtifactHandler
+}
+
+// ListSubresources calls List* on all subtypes below the supplied root name.
+// Specific revisions are not listed unless they are specified for the root name.
+// The passed filter is given to each List* function, regardless of type.
+// The passed HandlerSet is not checked for validity and must include all types
+// of handlers that may be called beneath the root.
+func ListSubresources(ctx context.Context,
+	adminClient connection.AdminClient,
+	client connection.RegistryClient,
+	root names.Name,
+	filter string,
+	getContents bool,
+	handler HandlerSet) (err error) {
+	switch name := root.(type) {
+	case names.Project:
+		err = core.ListProjects(ctx, adminClient, name, filter, handler.ProjectHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListAPIs(ctx, client, name.Api("-"), filter, handler.ApiHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Api("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListDeployments(ctx, client, name.Api("-").Deployment("-"), filter, handler.DeploymentHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Api("-").Deployment("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListVersions(ctx, client, name.Api("-").Version("-"), filter, handler.VersionHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Api("-").Version("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListSpecs(ctx, client, name.Api("-").Version("-").Spec("-"), filter, getContents, handler.SpecHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Api("-").Version("-").Spec("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.Api:
+		err = core.ListAPIs(ctx, client, name, filter, handler.ApiHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListDeployments(ctx, client, name.Deployment("-"), filter, handler.DeploymentHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Deployment("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListVersions(ctx, client, name.Version("-"), filter, handler.VersionHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Version("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListSpecs(ctx, client, name.Version("-").Spec("-"), filter, getContents, handler.SpecHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Version("-").Spec("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.Deployment:
+		err = core.ListDeployments(ctx, client, name, filter, handler.DeploymentHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.DeploymentRevision:
+		err = core.ListDeploymentRevisions(ctx, client, name, filter, handler.DeploymentHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.Version:
+		err = core.ListVersions(ctx, client, name, filter, handler.VersionHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListSpecs(ctx, client, name.Spec("-"), filter, getContents, handler.SpecHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Spec("-").Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.Spec:
+		err = core.ListSpecs(ctx, client, name, filter, getContents, handler.SpecHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.SpecRevision:
+		err = core.ListSpecRevisions(ctx, client, name, filter, getContents, handler.SpecHandler())
+		if err != nil {
+			return err
+		}
+		err = core.ListArtifacts(ctx, client, name.Artifact("-"), filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	case names.Artifact:
+		err = core.ListArtifacts(ctx, client, name, filter, getContents, handler.ArtifactHandler())
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/registry/cmd/root.go b/cmd/registry/cmd/root.go
index 3482ed1..5346d74 100644
--- a/cmd/registry/cmd/root.go
+++ b/cmd/registry/cmd/root.go
@@ -18,6 +18,7 @@ import (
 	"github.com/apigee/registry/cmd/registry/cmd/annotate"
 	"github.com/apigee/registry/cmd/registry/cmd/apply"
 	"github.com/apigee/registry/cmd/registry/cmd/auth"
+	"github.com/apigee/registry/cmd/registry/cmd/check"
 	"github.com/apigee/registry/cmd/registry/cmd/compute"
 	"github.com/apigee/registry/cmd/registry/cmd/config"
 	"github.com/apigee/registry/cmd/registry/cmd/count"
@@ -50,6 +51,7 @@ func Command() *cobra.Command {
 	cmd.AddCommand(annotate.Command())
 	cmd.AddCommand(apply.Command())
 	cmd.AddCommand(auth.Command())
+	cmd.AddCommand(check.Command())
 	cmd.AddCommand(compute.Command())
 	cmd.AddCommand(config.Command())
 	cmd.AddCommand(count.Command())
diff --git a/go.mod b/go.mod
index 633cb00..d2e5320 100644
--- a/go.mod
+++ b/go.mod
@@ -7,8 +7,6 @@ require (
 	cloud.google.com/go/pubsub v1.25.1
 	github.com/GoogleCloudPlatform/cloudsql-proxy v1.32.0
 	github.com/apex/log v1.9.0
-	github.com/getkin/kin-openapi v0.103.0
-	github.com/ghodss/yaml v1.0.0
 	github.com/google/cel-go v0.12.5
 	github.com/google/gnostic v0.6.9
 	github.com/google/go-cmp v0.5.9
@@ -18,7 +16,6 @@ require (
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.8.0
-	github.com/tufin/oasdiff v1.0.9
 	github.com/yoheimuta/go-protoparser/v4 v4.6.0
 	golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1
 	google.golang.org/api v0.98.0
@@ -35,16 +32,15 @@ require (
 	cloud.google.com/go/compute v1.10.0 // indirect
 	cloud.google.com/go/iam v0.5.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
-	github.com/invopop/yaml v0.2.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.5 // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 )
 
 require (
 	github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect
+	github.com/bmatcuk/doublestar/v4 v4.4.0
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
-	github.com/go-openapi/jsonpointer v0.19.5 // indirect
-	github.com/go-openapi/swag v0.22.3 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2
 	github.com/hashicorp/hcl v1.0.0 // indirect
@@ -59,10 +55,8 @@ require (
 	github.com/jackc/pgx/v4 v4.17.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
-	github.com/josharian/intern v1.0.0 // indirect
 	github.com/lib/pq v1.10.7
 	github.com/magiconair/properties v1.8.6 // indirect
-	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-sqlite3 v1.14.15
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/pelletier/go-toml v1.9.5 // indirect
@@ -87,5 +81,5 @@ require (
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
-	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v2 v2.4.0
 )
diff --git a/go.sum b/go.sum
index c83432c..72519e6 100644
--- a/go.sum
+++ b/go.sum
@@ -90,6 +90,8 @@ github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN
 github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
 github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic=
+github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
@@ -136,10 +138,6 @@ github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
-github.com/getkin/kin-openapi v0.87.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
-github.com/getkin/kin-openapi v0.103.0 h1:F5wAtaQvPWxKCAYZ69LgHAThgu16p4u41VQtbn1U8LA=
-github.com/getkin/kin-openapi v0.103.0/go.mod h1:w4lRPHiyOdwGbOkLIyk+P0qCwlu7TXPCHD/64nSXzgE=
-github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -147,11 +145,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
-github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
-github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -254,7 +247,6 @@ github.com/googleapis/gax-go/v2 v2.5.1 h1:kBRZU0PSuI7PspsSb/ChWoVResUcwNVIdpB049
 github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
 github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok=
 github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc=
@@ -270,9 +262,6 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
-github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
-github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
 github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
 github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@@ -328,8 +317,6 @@ github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
@@ -340,6 +327,7 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@@ -354,10 +342,6 @@ github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
 github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
 github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -440,8 +424,6 @@ github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj
 github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
 github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
 github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
-github.com/tufin/oasdiff v1.0.9 h1:eoUIJ0xwRAUHQLJXO+CCskHNFkK2pS1HTuui/RQByt8=
-github.com/tufin/oasdiff v1.0.9/go.mod h1:OASLqeA27MdLElf5p5L90Vl6O66ant3buWy6W1RX0Yk=
 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
@@ -452,7 +434,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
@@ -981,6 +962,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
@@ -991,14 +973,12 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
diff --git a/server/registry/names/names.go b/server/registry/names/names.go
new file mode 100644
index 0000000..9222eed
--- /dev/null
+++ b/server/registry/names/names.go
@@ -0,0 +1,69 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package names
+
+import "fmt"
+
+// Parse attempts to parse a string into an resource name.
+func Parse(name string) (Name, error) {
+	if collection, err := ParseResourceCollection(name); err == nil {
+		return collection, err
+	}
+	return ParseResourceEntity(name)
+}
+
+// Parse attempts to parse a string into a resource collection name.
+func ParseResourceCollection(name string) (Name, error) {
+	if project, err := ParseProjectCollection(name); err == nil {
+		return project, err
+	} else if api, err := ParseApiCollection(name); err == nil {
+		return api, err
+	} else if deployment, err := ParseDeploymentCollection(name); err == nil {
+		return deployment, err
+	} else if rev, err := ParseDeploymentRevisionCollection(name); err == nil {
+		return rev, err
+	} else if version, err := ParseVersionCollection(name); err == nil {
+		return version, err
+	} else if spec, err := ParseSpecCollection(name); err == nil {
+		return spec, err
+	} else if rev, err := ParseSpecRevisionCollection(name); err == nil {
+		return rev, err
+	} else if artifact, err := ParseArtifactCollection(name); err == nil {
+		return artifact, err
+	}
+	return nil, fmt.Errorf("invalid name: %s", name)
+}
+
+// Parse attempts to parse a string into a single resource name.
+func ParseResourceEntity(name string) (Name, error) {
+	if project, err := ParseProject(name); err == nil {
+		return project, err
+	} else if api, err := ParseApi(name); err == nil {
+		return api, err
+	} else if deployment, err := ParseDeployment(name); err == nil {
+		return deployment, err
+	} else if rev, err := ParseDeploymentRevision(name); err == nil {
+		return rev, err
+	} else if version, err := ParseVersion(name); err == nil {
+		return version, err
+	} else if spec, err := ParseSpec(name); err == nil {
+		return spec, err
+	} else if rev, err := ParseSpecRevision(name); err == nil {
+		return rev, err
+	} else if artifact, err := ParseArtifact(name); err == nil {
+		return artifact, err
+	}
+	return nil, fmt.Errorf("invalid name: %s", name)
+}


Test patch:
diff --git a/cmd/registry/cmd/check/check_test.go b/cmd/registry/cmd/check/check_test.go
new file mode 100644
index 0000000..ce50b60
--- /dev/null
+++ b/cmd/registry/cmd/check/check_test.go
@@ -0,0 +1,74 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package check
+
+import (
+	"bytes"
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/connection/grpctest"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry"
+	"github.com/apigee/registry/server/registry/test/seeder"
+)
+
+// TestMain will set up a local RegistryServer and grpc.Server for all
+// tests in this package if APG_REGISTRY_ADDRESS env var is not set
+// for the client.
+func TestMain(m *testing.M) {
+	grpctest.TestMain(m, registry.Config{})
+}
+
+func TestCheck(t *testing.T) {
+	ctx := context.Background()
+	registryClient, err := connection.NewRegistryClient(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create client: %+v", err)
+	}
+	t.Cleanup(func() { registryClient.Close() })
+	adminClient, err := connection.NewAdminClient(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create client: %+v", err)
+	}
+	t.Cleanup(func() { adminClient.Close() })
+	client := seeder.Client{
+		RegistryClient: registryClient,
+		AdminClient:    adminClient,
+	}
+	spec := &rpc.ApiSpec{
+		Name:     "projects/my-project/locations/global/apis/a/versions/v/specs/bad",
+		MimeType: "application/html",
+		Contents: []byte("some text"),
+	}
+	if err := seeder.SeedSpecs(ctx, client, spec); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+
+	buf := &bytes.Buffer{}
+	cmd := Command()
+	args := []string{"projects/my-project"}
+	cmd.SetArgs(args)
+	cmd.SetOut(buf)
+	cmd.SetErr(buf)
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("Execute() with args %v returned error: %s", args, err)
+	}
+	if !strings.Contains(buf.String(), `Unexpected mime_type "application/html"`) {
+		t.Errorf("unexpected result: %s", buf)
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/checker_test.go b/cmd/registry/cmd/check/lint/checker_test.go
new file mode 100644
index 0000000..4670751
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/checker_test.go
@@ -0,0 +1,225 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/connection/grpctest"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry"
+	"github.com/apigee/registry/server/registry/names"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+// TestMain will set up a local RegistryServer and grpc.Server for all
+// tests in this package if APG_REGISTRY_ADDRESS env var is not set
+// for the client.
+func TestMain(m *testing.M) {
+	grpctest.TestMain(m, registry.Config{})
+}
+
+func TestChecker_run(t *testing.T) {
+	defaultConfigs := Configs{}
+
+	testRuleName := NewRuleName(111, "test-rule")
+	ruleProblems := []Problem{{
+		Message:  "rule1_problem",
+		Location: "projects/test",
+		RuleID:   testRuleName,
+	}}
+
+	tests := []struct {
+		testName string
+		configs  Configs
+		problems []Problem
+	}{
+		{"Empty", Configs{}, []Problem{}},
+		{
+			"NonMatchingFile",
+			append(
+				defaultConfigs,
+				Config{
+					IncludedPaths: []string{"nofile"},
+				},
+			),
+			ruleProblems,
+		},
+		{
+			"NonMatchingRule",
+			append(
+				defaultConfigs,
+				Config{
+					DisabledRules: []string{"foo::bar"},
+				},
+			),
+			ruleProblems,
+		},
+		{
+			"DisabledRule",
+			append(
+				defaultConfigs,
+				Config{
+					DisabledRules: []string{string(testRuleName)},
+				},
+			),
+			[]Problem{},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			rules := NewRuleRegistry()
+			err := rules.Register(111, &ProjectRule{
+				Name: NewRuleName(111, "test-rule"),
+				ApplyToProject: func(p *rpc.Project) []Problem {
+					return test.problems
+				},
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			ctx := context.Background()
+			client, err := connection.NewRegistryClient(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { client.Close() })
+			admin, err := connection.NewAdminClient(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { admin.Close() })
+
+			err = admin.DeleteProject(ctx, &rpc.DeleteProjectRequest{
+				Name:  "projects/test",
+				Force: true,
+			})
+			if err != nil && status.Code(err) != codes.NotFound {
+				t.Fatalf("Error deleting test project: %+v", err)
+			}
+			// Create the test project.
+			_, err = admin.CreateProject(ctx, &rpc.CreateProjectRequest{
+				ProjectId: "test",
+				Project: &rpc.Project{
+					DisplayName: "Test",
+					Description: "A test catalog",
+				},
+			})
+			if err != nil {
+				t.Fatalf("Error creating project %s", err)
+			}
+
+			l := New(rules, test.configs)
+			root := names.Project{
+				ProjectID: "test",
+			}
+			resp, err := l.Check(ctx, admin, client, root, "", 10)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if diff := cmp.Diff(test.problems, resp.Problems, cmpopts.IgnoreUnexported(Problem{})); diff != "" {
+				t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestChecker_panic(t *testing.T) {
+	testRuleNum := 111
+
+	tests := []struct {
+		testName string
+		rule     Rule
+	}{
+		{
+			testName: "Panic",
+			rule: &ProjectRule{
+				Name: NewRuleName(testRuleNum, "panic"),
+				ApplyToProject: func(p *rpc.Project) []Problem {
+					panic("panic")
+				},
+			},
+		},
+		{
+			testName: "PanicError",
+			rule: &ProjectRule{
+				Name: NewRuleName(testRuleNum, "panic"),
+				ApplyToProject: func(p *rpc.Project) []Problem {
+					panic(fmt.Errorf("panic"))
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			rules := NewRuleRegistry()
+			err := rules.Register(testRuleNum, test.rule)
+			if err != nil {
+				t.Fatalf("Failed to create Rules: %q", err)
+			}
+
+			ctx := context.Background()
+			client, err := connection.NewRegistryClient(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { client.Close() })
+			admin, err := connection.NewAdminClient(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			t.Cleanup(func() { admin.Close() })
+
+			err = admin.DeleteProject(ctx, &rpc.DeleteProjectRequest{
+				Name:  "projects/test",
+				Force: true,
+			})
+			if err != nil && status.Code(err) != codes.NotFound {
+				t.Fatalf("Error deleting test project: %+v", err)
+			}
+			// Create the test project.
+			_, err = admin.CreateProject(ctx, &rpc.CreateProjectRequest{
+				ProjectId: "test",
+				Project: &rpc.Project{
+					DisplayName: "Test",
+					Description: "A test catalog",
+				},
+			})
+			if err != nil {
+				t.Fatalf("Error creating project %s", err)
+			}
+
+			l := New(rules, nil)
+			root := names.Project{
+				ProjectID: "test",
+			}
+			_, err = l.Check(ctx, admin, client, root, "", 10)
+			if err == nil || !strings.Contains(err.Error(), "panic") {
+				t.Fatalf("Expected error with panic, got: %v", err)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/config_test.go b/cmd/registry/cmd/check/lint/config_test.go
new file mode 100644
index 0000000..11660f7
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/config_test.go
@@ -0,0 +1,433 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestRuleConfigs_IsRuleEnabled(t *testing.T) {
+	enabled := true
+	disabled := false
+
+	tests := []struct {
+		name    string
+		configs Configs
+		path    string
+		rule    string
+		want    bool
+	}{
+		{"EmptyConfig", nil, "a", "b", enabled},
+		{
+			"NoConfigMatched_Enabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a.proto"},
+					DisabledRules: []string{"testrule"},
+				},
+			},
+			"b.proto",
+			"testrule",
+			enabled,
+		},
+		{
+			"PathMatched_DisabledRulesNotMatch_Enabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a.proto"},
+					DisabledRules: []string{"somerule"},
+				},
+			},
+			"a.proto",
+			"testrule",
+			enabled,
+		},
+		{
+			"PathExactMatched_DisabledRulesMatched_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a.proto"},
+					DisabledRules: []string{"somerule", "testrule"},
+				},
+			},
+			"a.proto",
+			"testrule",
+			disabled,
+		},
+		{
+			"PathExactMatched_DisabledRulesMatchedAll_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a.proto"},
+					DisabledRules: []string{"all"},
+				},
+			},
+			"a.proto",
+			"testrule",
+			disabled,
+		},
+		{
+			"PathDoubleStartMatched_DisabledRulesMatched_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a/**/*.proto"},
+					DisabledRules: []string{"somerule", "testrule"},
+				},
+			},
+			"a/with/long/sub/dir/etc/ory/e.proto",
+			"testrule",
+			disabled,
+		},
+		{
+			"PathMatched_DisabledRulesPrefixMatched_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a/b/c.proto"},
+					DisabledRules: []string{"parent"},
+				},
+			},
+			"a/b/c.proto",
+			"parent::test_rule",
+			disabled,
+		},
+		{
+			"PathMatched_DisabledRulesSuffixMatched_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a/b/c.proto"},
+					DisabledRules: []string{"child"},
+				},
+			},
+			"a/b/c.proto",
+			"parent::child",
+			disabled,
+		},
+		{
+			"PathMatched_DisabledRulesMiddleMatched_Disabled",
+			Configs{
+				{
+					IncludedPaths: []string{"a/b/c.proto"},
+					DisabledRules: []string{"middle"},
+				},
+			},
+			"a/b/c.proto",
+			"parent::middle::child",
+			disabled,
+		},
+		{
+			"EmptyIncludePath_ConfigMatched_DisabledRulesMatched_Disabled",
+			Configs{
+				{
+					DisabledRules: []string{"testrule"},
+				},
+			},
+			"a.proto",
+			"testrule",
+			disabled,
+		},
+		{
+			"ExcludedPathMatch_ConfigNotMatched_DisabledRulesMatched_Enabled",
+			Configs{
+				{
+					ExcludedPaths: []string{"a.proto"},
+					DisabledRules: []string{"testrule"},
+				},
+			},
+			"a.proto",
+			"testrule",
+			enabled,
+		},
+		{
+			"TwoConfigs_Override_Enabled",
+			Configs{
+				{
+					DisabledRules: []string{"testrule"},
+				},
+				{
+					EnabledRules: []string{"testrule::a"},
+				},
+			},
+			"a.proto",
+			"testrule::a",
+			enabled,
+		},
+		{
+			"TwoConfigs_Override_Disabled",
+			Configs{
+				{
+					EnabledRules: []string{"testrule"},
+				},
+				{
+					DisabledRules: []string{"testrule::a"},
+				},
+			},
+			"a.proto",
+			"testrule::a",
+			disabled,
+		},
+		{
+			"TwoConfigs_DoubleEnable_Enabled",
+			Configs{
+				{
+					EnabledRules: []string{"testrule"},
+				},
+				{
+					EnabledRules: []string{"testrule::a"},
+				},
+			},
+			"a.proto",
+			"testrule::a",
+			enabled,
+		},
+		{
+			"TwoConfigs_DoubleDisabled_Disabled",
+			Configs{
+				{
+					DisabledRules: []string{"testrule"},
+				},
+				{
+					DisabledRules: []string{"testrule::a"},
+				},
+			},
+			"a.proto",
+			"testrule::a",
+			disabled,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := test.configs.IsRuleEnabled(test.rule, test.path)
+
+			if got != test.want {
+				t.Errorf("IsRuleEnabled: got %t, but want %t", got, test.want)
+			}
+		})
+	}
+}
+
+type errReader int
+
+func (errReader) Read(p []byte) (int, error) {
+	return 0, errors.New("test error")
+}
+
+func TestReadConfigsJSONReaderError(t *testing.T) {
+	if _, err := ReadConfigsJSON(errReader(0)); err == nil {
+		t.Error("ReadConfigsJSON expects an error")
+	}
+}
+
+func TestReadConfigsJSONFormatError(t *testing.T) {
+	invalidJSON := `
+	[
+		{
+			"included_paths": ["path_a"],
+			"excluded_paths": ["path_b"],
+			"disabled_rules": ["rule_a", "rule_b"],
+			"enabled_rules": ["rule_c", "rule_d"]
+		}
+	`
+
+	if _, err := ReadConfigsJSON(strings.NewReader(invalidJSON)); err == nil {
+		t.Error("ReadConfigsJSON expects an error")
+	}
+}
+
+func TestReadConfigsJSON(t *testing.T) {
+	content := `
+	[
+		{
+			"included_paths": ["path_a"],
+			"excluded_paths": ["path_b"],
+			"disabled_rules": ["rule_a", "rule_b"],
+			"enabled_rules": ["rule_c", "rule_d"]
+		}
+	]
+	`
+
+	configs, err := ReadConfigsJSON(strings.NewReader(content))
+	if err != nil {
+		t.Errorf("ReadConfigsJSON returns error: %v", err)
+	}
+
+	expected := Configs{
+		{
+			IncludedPaths: []string{"path_a"},
+			ExcludedPaths: []string{"path_b"},
+			DisabledRules: []string{"rule_a", "rule_b"},
+			EnabledRules:  []string{"rule_c", "rule_d"},
+		},
+	}
+	if !reflect.DeepEqual(configs, expected) {
+		t.Errorf("ReadConfigsJSON returns %v, but want %v", configs, expected)
+	}
+}
+
+func TestReadConfigsYAMLReaderError(t *testing.T) {
+	if _, err := ReadConfigsYAML(errReader(0)); err == nil {
+		t.Error("ReadConfigsYAML expects an error")
+	}
+}
+
+func TestReadConfigsYAMLFormatError(t *testing.T) {
+	invalidYAML := `
+	[
+		{
+			"included_paths": ["path_a"],
+			"excluded_paths": ["path_b"],
+			"disabled_rules": ["rule_a", "rule_b"],
+			"enabled_rules": ["rule_c", "rule_d"]
+		}
+	`
+
+	if _, err := ReadConfigsYAML(strings.NewReader(invalidYAML)); err == nil {
+		t.Error("ReadConfigsYAML expects an error")
+	}
+}
+
+func TestReadConfigsYAML(t *testing.T) {
+	content := `
+---
+- included_paths:
+    - 'path_a'
+  excluded_paths:
+    - 'path_b'
+  disabled_rules:
+    - 'rule_a'
+    - 'rule_b'
+  enabled_rules:
+    - 'rule_c'
+    - 'rule_d'
+`
+
+	configs, err := ReadConfigsYAML(strings.NewReader(content))
+	if err != nil {
+		t.Errorf("ReadConfigsYAML returns error: %v", err)
+	}
+
+	expected := Configs{
+		{
+			IncludedPaths: []string{"path_a"},
+			ExcludedPaths: []string{"path_b"},
+			DisabledRules: []string{"rule_a", "rule_b"},
+			EnabledRules:  []string{"rule_c", "rule_d"},
+		},
+	}
+	if !reflect.DeepEqual(configs, expected) {
+		t.Errorf("ReadConfigsYAML returns %v, but want %v", configs, expected)
+	}
+}
+
+func TestReadConfigsFromFile(t *testing.T) {
+	expectedConfigs := Configs{
+		{
+			IncludedPaths: []string{"path_a"},
+			ExcludedPaths: []string{"path_b"},
+			DisabledRules: []string{"rule_a", "rule_b"},
+			EnabledRules:  []string{"rule_c", "rule_d"},
+		},
+	}
+
+	jsonConfigsText := `
+	[
+		{
+			"included_paths": ["path_a"],
+			"excluded_paths": ["path_b"],
+			"disabled_rules": ["rule_a", "rule_b"],
+			"enabled_rules": ["rule_c", "rule_d"]
+		}
+	]
+	`
+	jsonConfigsFile := createTempFile(t, "test.json", jsonConfigsText)
+	defer os.Remove(jsonConfigsFile)
+
+	yamlConfigsText := `
+---
+- included_paths:
+    - 'path_a'
+  excluded_paths:
+    - 'path_b'
+  disabled_rules:
+    - 'rule_a'
+    - 'rule_b'
+  enabled_rules:
+    - 'rule_c'
+    - 'rule_d'
+`
+	yamlConfigsFile := createTempFile(t, "test.yaml", yamlConfigsText)
+	defer os.Remove(yamlConfigsFile)
+
+	tests := []struct {
+		name     string
+		filePath string
+		configs  Configs
+		hasErr   bool
+	}{
+		{
+			"JSON file",
+			jsonConfigsFile,
+			expectedConfigs,
+			false,
+		},
+		{
+			"YAML file",
+			yamlConfigsFile,
+			expectedConfigs,
+			false,
+		},
+		{
+			"Invalid file extension",
+			"test.abc",
+			nil,
+			true,
+		},
+		{
+			"File not existed",
+			"not-existed-file.json",
+			nil,
+			true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			configs, err := ReadConfigsFromFile(test.filePath)
+			if (err != nil) != test.hasErr {
+				t.Errorf("ReadConfigsFromFile got error %v, but want %v", err, test.hasErr)
+			}
+			if err != nil {
+				if !reflect.DeepEqual(configs, test.configs) {
+					t.Errorf("ReadConfigsFromFile got configs %v, but want %v", configs, test.configs)
+				}
+			}
+		})
+	}
+}
+
+func createTempFile(t *testing.T, name, content string) string {
+	dir, err := os.MkdirTemp("", "config_tests")
+	if err != nil {
+		t.Fatal(err)
+	}
+	filePath := filepath.Join(dir, name)
+	if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	return filePath
+}
diff --git a/cmd/registry/cmd/check/lint/problem_test.go b/cmd/registry/cmd/check/lint/problem_test.go
new file mode 100644
index 0000000..783da13
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/problem_test.go
@@ -0,0 +1,77 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"gopkg.in/yaml.v2"
+)
+
+func TestProblemJSON(t *testing.T) {
+	problem := &Problem{
+		Message:  "foo bar",
+		Location: "test/location",
+		RuleID:   "core::0131",
+	}
+	serialized, err := json.Marshal(problem)
+	if err != nil {
+		t.Fatalf("Could not marshal Problem to JSON.")
+	}
+	tests := []struct {
+		testName string
+		token    string
+	}{
+		{"Message", `"message":"foo bar"`},
+		{"Location", `"location":"test/location"`},
+		{"RuleID", `"rule_id":"core::0131"`},
+	}
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			if !strings.Contains(string(serialized), test.token) {
+				t.Errorf("Got\n%v\nExpected `%s` to be present.", string(serialized), test.token)
+			}
+		})
+	}
+}
+
+func TestProblemYAML(t *testing.T) {
+	problem := &Problem{
+		Message:  "foo bar",
+		Location: "test/location",
+		RuleID:   "core::0131",
+	}
+	serialized, err := yaml.Marshal(problem)
+	if err != nil {
+		t.Fatalf("Could not marshal Problem to YAML.")
+	}
+	tests := []struct {
+		testName string
+		token    string
+	}{
+		{"Message", `message: foo bar`},
+		{"Location", `location: test/location`},
+		{"RuleID", `rule_id: core::0131`},
+	}
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			if !strings.Contains(string(serialized), test.token) {
+				t.Errorf("Got\n%v\nExpected `%s` to be present.", string(serialized), test.token)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/rule_groups_test.go b/cmd/registry/cmd/check/lint/rule_groups_test.go
new file mode 100644
index 0000000..c021287
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_groups_test.go
@@ -0,0 +1,41 @@
+package lint
+
+import (
+	"testing"
+)
+
+func TestGroups(t *testing.T) {
+	tests := []struct {
+		name   string
+		ruleID int
+		group  string
+	}{
+		{"registry", 1, "registry"},
+		{"registry", 999, "registry"},
+		{"hub", 1000, "hub"},
+		{"hub", 1099, "hub"},
+		{"controller", 1100, "controller"},
+		{"controller", 1199, "controller"},
+		{"score", 1200, "score"},
+		{"score", 1299, "score"},
+		{"style", 1300, "style"},
+		{"style", 1399, "style"},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if got := getRuleGroup(test.ruleID, ruleGroup); got != test.group {
+				t.Errorf("ruleID(%d) got %s, but want %s", test.ruleID, got, test.group)
+			}
+		})
+	}
+}
+
+func TestGetRuleGroupPanic(t *testing.T) {
+	var groups []func(int) string
+	defer func() {
+		if r := recover(); r == nil {
+			t.Errorf("getRuleGroup did not panic")
+		}
+	}()
+	getRuleGroup(0, groups)
+}
diff --git a/cmd/registry/cmd/check/lint/rule_name_test.go b/cmd/registry/cmd/check/lint/rule_name_test.go
new file mode 100644
index 0000000..dc4f3f2
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_name_test.go
@@ -0,0 +1,112 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"testing"
+)
+
+func TestRuleNameValid(t *testing.T) {
+	tests := []struct {
+		testName string
+		ruleName RuleName
+	}{
+		{"Lower", "aip"},
+		{"LowerNumber", "aip0121"},
+		{"LowerNumberKebab", "aip-0121"},
+		{"Namespaced", "aip::0121"},
+		{"NamespacedHyphen", "core::aip-0121"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			if !test.ruleName.IsValid() {
+				t.Errorf("Rule name %q is invalid; want valid.", test.ruleName)
+			}
+		})
+	}
+}
+
+func TestRuleNameInvalid(t *testing.T) {
+	tests := []struct {
+		testName string
+		ruleName RuleName
+	}{
+		{"EmptyString", ""},
+		{"TripleColon", "a:::b"},
+		{"QuadrupleColon", "a::::b"},
+		{"CapitalLetter", "A"},
+		{"LeadingDoubleColon", "::my-rule"},
+		{"TrailingDoubleColon", "my-namespace::"},
+		{"LeadingHyphen", "-core::aip-0131"},
+		{"LeadingSegmentHyphen", "core::-aip-0131"},
+		{"OnlyHyphen", "-"},
+		{"SingleColon", "core:aip-0131"},
+		{"Underscore", "core::aip_0131"},
+		{"CamelCase", "myRule"},
+		{"PascalCase", "MyRule"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			if test.ruleName.IsValid() {
+				t.Errorf("Rule name %q is valid; want invalid.", test.ruleName)
+			}
+		})
+	}
+}
+
+func TestNewRuleName(t *testing.T) {
+	tests := []struct {
+		testName string
+		aip      int
+		name     string
+		want     string
+	}{
+		{"ZeroPad", 131, "http-method", "registry::0131::http-method"},
+	}
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			rn := NewRuleName(test.aip, test.name)
+			if got := string(rn); got != test.want {
+				t.Errorf("Got %q, expected %q.", got, test.want)
+			}
+		})
+	}
+}
+
+func TestRuleName_HasPrefix(t *testing.T) {
+	tests := []struct {
+		r         RuleName
+		prefix    []string
+		hasPrefix bool
+	}{
+		{"a::b::c", []string{"a", "b"}, true},
+		{"a::b::c", []string{"a"}, true},
+		{"a::b::c", []string{"a::b"}, true},
+		{"a::b::c::d", []string{"a::b", "c"}, true},
+		{"a::b::c", []string{"a::b::c"}, true},
+		{"ab::b::c", []string{"a"}, false},
+	}
+
+	for _, test := range tests {
+		if test.r.HasPrefix(test.prefix...) != test.hasPrefix {
+			t.Errorf(
+				"%q.HasPrefix(%v)=%t; want %t",
+				test.r, test.prefix, test.r.HasPrefix(test.prefix...), test.hasPrefix,
+			)
+		}
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/rule_registry_test.go b/cmd/registry/cmd/check/lint/rule_registry_test.go
new file mode 100644
index 0000000..9dbe43c
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_registry_test.go
@@ -0,0 +1,67 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"testing"
+)
+
+func TestRuleRegistryRegister(t *testing.T) {
+	tests := []struct {
+		name      string
+		aip       int
+		ruleNames []RuleName
+		err       error
+	}{
+		{
+			name:      "Registered_Okay",
+			aip:       111,
+			ruleNames: []RuleName{NewRuleName(111, "a"), NewRuleName(111, "b")},
+			err:       nil,
+		},
+		{
+			name:      "InvalidRuleName",
+			aip:       111,
+			ruleNames: []RuleName{NewRuleName(111, "")},
+			err:       errInvalidRuleName,
+		},
+		{
+			name:      "InvalidRuleGroup",
+			aip:       111,
+			ruleNames: []RuleName{NewRuleName(100, "a")},
+			err:       errInvalidRuleGroup,
+		},
+		{
+			name:      "Duplicated",
+			aip:       111,
+			ruleNames: []RuleName{NewRuleName(111, "a"), NewRuleName(111, "a")},
+			err:       errDuplicatedRuleName,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			rules := []Rule{}
+			for _, name := range test.ruleNames {
+				rules = append(rules, &FieldRule{Name: name})
+			}
+
+			registry := NewRuleRegistry()
+			err := registry.Register(test.aip, rules...)
+			if err != test.err {
+				t.Errorf("Register(): got %v, but want %v", err, test.err)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/rule_test.go b/cmd/registry/cmd/check/lint/rule_test.go
new file mode 100644
index 0000000..243b739
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_test.go
@@ -0,0 +1,213 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lint
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/apigee/registry/rpc"
+)
+
+func TestProjectRule(t *testing.T) {
+	resource := &rpc.Project{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ProjectRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.Project) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToProject: func(p *rpc.Project) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestArtifactRule(t *testing.T) {
+	resource := &rpc.Artifact{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ArtifactRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.Artifact) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToArtifact: func(p *rpc.Artifact) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestApiRule(t *testing.T) {
+	resource := &rpc.Api{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ApiRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.Api) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToApi: func(p *rpc.Api) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestApiDeploymentRule(t *testing.T) {
+	resource := &rpc.ApiDeployment{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ApiDeploymentRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.ApiDeployment) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToApiDeployment: func(p *rpc.ApiDeployment) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestApiVersion(t *testing.T) {
+	resource := &rpc.ApiVersion{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ApiVersionRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.ApiVersion) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToApiVersion: func(p *rpc.ApiVersion) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestApiSpec(t *testing.T) {
+	resource := &rpc.ApiSpec{
+		Name: "resource/myresource",
+	}
+	for _, test := range makeRuleTests(resource) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &ApiSpecRule{
+				Name: RuleName("test"),
+				OnlyIf: func(p *rpc.ApiSpec) bool {
+					return p.GetName() == resource.Name
+				},
+				ApplyToApiSpec: func(p *rpc.ApiSpec) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, resource, t)
+		})
+	}
+}
+
+func TestFieldRule(t *testing.T) {
+	project := &rpc.Project{
+		Name: "projects/myproject",
+	}
+
+	// Iterate over the tests and run them.
+	for _, test := range makeRuleTests(project) {
+		t.Run(test.testName, func(t *testing.T) {
+			rule := &FieldRule{
+				Name: RuleName("test"),
+				OnlyIf: func(resource Resource, name string) bool {
+					return name == "Name"
+				},
+				ApplyToField: func(resource Resource, name string, value interface{}) []Problem {
+					return test.problems
+				},
+			}
+
+			test.runRule(rule, project, t)
+		})
+	}
+}
+
+type ruleTest struct {
+	testName string
+	problems []Problem
+}
+
+// runRule runs a rule within a test environment.
+func (test *ruleTest) runRule(rule Rule, r Resource, t *testing.T) {
+	t.Helper()
+
+	// Establish that the metadata methods work.
+	if got, want := string(rule.GetName()), string(RuleName("test")); got != want {
+		t.Errorf("Got %q for GetName(), expected %q", got, want)
+	}
+
+	// Run the rule's lint function on the file descriptor
+	if got, want := rule.Apply(r), test.problems; !reflect.DeepEqual(got, want) {
+		t.Errorf("Got %v problems; expected %v.", got, want)
+	}
+}
+
+// makeRuleTests generates boilerplate tests that are consistent for
+// each type of rule.
+func makeRuleTests(r Resource) []ruleTest {
+	return []ruleTest{
+		{"NoProblems", nil},
+		{"OneProblem", []Problem{{
+			Message:  "There was a problem.",
+			Location: r.GetName(),
+		}}},
+		{"TwoProblems", []Problem{
+			{
+				Message:  "This was the first problem.",
+				Location: r.GetName(),
+			},
+			{
+				Message:  "This was the second problem.",
+				Location: r.GetName(),
+			},
+		}},
+	}
+}
diff --git a/cmd/registry/cmd/check/lint/rule_urls_test.go b/cmd/registry/cmd/check/lint/rule_urls_test.go
new file mode 100644
index 0000000..48dd4ef
--- /dev/null
+++ b/cmd/registry/cmd/check/lint/rule_urls_test.go
@@ -0,0 +1,77 @@
+package lint
+
+import (
+	"testing"
+)
+
+func TestCoreRuleURL(t *testing.T) {
+	tests := []struct {
+		name string
+		rule string
+		url  string
+	}{
+		{"CoreRule", "core::0122::camel-case-uris", baseURL + "122/camel-case-uris"},
+		{"NotCoreRule", "test::0122::camel-case-uris", ""},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if got := coreRuleURL(test.rule); got != test.url {
+				t.Errorf("coreRuleURL(%s) got %s, but want %s", test.name, got, test.url)
+			}
+		})
+	}
+}
+
+func TestHubRuleURL(t *testing.T) {
+	tests := []struct {
+		name string
+		rule string
+		url  string
+	}{
+		{"HubRule", "hub::1111::repeated-fields", baseURL + "1111/repeated-fields"},
+		{"NotHubRule", "test::0122::camel-case-uris", ""},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if got := hubRuleUrl(test.rule); got != test.url {
+				t.Errorf("hubRuleUrl(%s) got %s, but want %s", test.name, got, test.url)
+			}
+		})
+	}
+}
+
+func TestGetRuleURL(t *testing.T) {
+	mapping1 := func(name string) string {
+		if name == "one" {
+			return "ONE"
+		}
+		return ""
+	}
+	mapping2 := func(name string) string {
+		if name == "two" {
+			return "TWO"
+		}
+		return ""
+	}
+	ruleURLMappings := []func(string) string{mapping1, mapping2}
+
+	tests := []struct {
+		name     string
+		ruleName string
+		ruleURL  string
+	}{
+		{"MappingOne", "one", "ONE"},
+		{"MappingTwo", "two", "TWO"},
+		{"NoMapping", "zero", ""},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if got := getRuleURL(test.ruleName, ruleURLMappings); got != test.ruleURL {
+				t.Errorf("getRuleURL(%s) got %s, but want %s", test.ruleName, got, test.ruleURL)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/rule0001/mime_type_contents_test.go b/cmd/registry/cmd/check/rules/rule0001/mime_type_contents_test.go
new file mode 100644
index 0000000..e927352
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule0001/mime_type_contents_test.go
@@ -0,0 +1,128 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rule0001
+
+import (
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/rpc"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
+)
+
+func TestContentsMimeType(t *testing.T) {
+	tests := []struct {
+		name     string
+		mimeType string
+		contents []byte
+		problems []lint.Problem
+	}{
+		{
+			"empty content",
+			"text/plain",
+			nil,
+			nil,
+		},
+		{
+			"type match",
+			"text/plain",
+			[]byte("string"),
+			nil,
+		},
+		{
+			"type and parameter match",
+			"text/plain;charset=utf-8",
+			[]byte("string"),
+			nil,
+		},
+		{
+			"wrong type",
+			"text/html",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:    `Unexpected mime_type "text/html" for contents.`,
+				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
+				Severity:   lint.WARNING,
+			}},
+		},
+		{
+			"empty type",
+			"",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:    `Empty mime_type.`,
+				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
+				Severity:   lint.ERROR,
+			}},
+		},
+		{
+			"bad type",
+			"bad/",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:  `Unable to parse mime_type "bad/": mime: expected token after slash.`,
+				Severity: lint.ERROR,
+			}},
+		},
+		{
+			"score",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.scoring.Score",
+			createScore(),
+			nil,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			resources := []lint.Resource{
+				&rpc.ApiSpec{
+					Name:     "ApiSpec",
+					MimeType: test.mimeType,
+					Contents: test.contents,
+				},
+				&rpc.Artifact{
+					Name:     "Artifact",
+					MimeType: test.mimeType,
+					Contents: test.contents,
+				},
+			}
+			for _, resource := range resources {
+				t.Run(resource.GetName(), func(t *testing.T) {
+					if !mimeTypeContents.OnlyIf(resource, "MimeType") {
+						t.Error("invalid OnlyIf")
+					}
+					got := mimeTypeContents.Apply(resource)
+					for i := range test.problems {
+						test.problems[i].Location = resource.GetName() + "::MimeType"
+					}
+					if diff := cmp.Diff(test.problems, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+						t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+					}
+				})
+			}
+		})
+	}
+}
+
+func createScore() []byte {
+	s := &rpc.Score{
+		Id:    "score",
+		Kind:  "Score",
+		Value: &rpc.Score_IntegerValue{},
+	}
+	b, _ := proto.Marshal(s)
+	return b
+}
diff --git a/cmd/registry/cmd/check/rules/rule0001/rule0001_test.go b/cmd/registry/cmd/check/rules/rule0001/rule0001_test.go
new file mode 100644
index 0000000..8fd9e80
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule0001/rule0001_test.go
@@ -0,0 +1,27 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rule0001
+
+import (
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}