diff --git a/.github/workflows/schema-sync.yml b/.github/workflows/schema-sync.yml new file mode 100644 index 0000000..ed0e35a --- /dev/null +++ b/.github/workflows/schema-sync.yml @@ -0,0 +1,80 @@ +name: Schema sync + +on: + schedule: + - cron: '41 6 * * 1' + workflow_dispatch: + +permissions: {} + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 15 + # The pull request is opened with the housekeeper app's token, so the job's own token only needs + # to read the repository for the checkout. + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Refresh vendored schemas from upstream + run: make update-schemas + + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.HOUSEKEEPER_CLIENT_ID }} + private-key: ${{ secrets.HOUSEKEEPER_PRIVATE_KEY }} + + - name: Create PR for Schema Update + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -e + + changed=$(git status --porcelain -- 'schema/data' | awk '{print $2}') + if [ -z "$changed" ]; then + echo "Vendored schemas are up to date." + exit 0 + fi + + OWNER="${GITHUB_REPOSITORY_OWNER}" + REPO="${GITHUB_REPOSITORY#*/}" + BRANCH="housekeeper/schema-update-${GITHUB_RUN_ID}" + MESSAGE="chore: update vendored Dev Container schemas" + + gh api "repos/${OWNER}/${REPO}/git/refs" \ + --method POST \ + -F ref="refs/heads/${BRANCH}" \ + -F sha="${GITHUB_SHA}" + + while IFS= read -r file; do + existing_sha=$(gh api "repos/${OWNER}/${REPO}/contents/${file}?ref=${BRANCH}" \ + --jq '.sha' 2>/dev/null || true) + args=( + --method PUT + -F message="${MESSAGE}" + -F content="$(base64 -w0 "$file")" + -F branch="${BRANCH}" + ) + [ -n "$existing_sha" ] && args+=(-F sha="$existing_sha") + gh api "repos/${OWNER}/${REPO}/contents/${file}" "${args[@]}" + done <<< "$changed" + + # The upstream commits the schemas were vendored from, so the review shows what moved. + spec=$(jq -r '.spec' schema/data/REVISIONS.json) + vscode=$(jq -r '.vscode' schema/data/REVISIONS.json) + files=$(echo "$changed" | sed 's|^schema/data/|- |') + + gh pr create \ + --title "${MESSAGE}" \ + --body "$(printf '## Updated Schemas\n\n%s\n\n## Upstream Revisions\n\n- devcontainers/spec@%s\n- microsoft/vscode@%s\n' "${files}" "${spec}" "${vscode}")" \ + --head "${BRANCH}" \ + --base main diff --git a/Makefile b/Makefile index bb617de..a0f44d4 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ coverage: ## Run tests and open an HTML coverage report lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run +.PHONY: update-schemas +update-schemas: ## Refresh the vendored Dev Container schemas from upstream + go run ./cmd/updateschemas + .PHONY: clean clean: ## Remove build artifacts rm -rf bin coverage.out coverage.html diff --git a/README.md b/README.md index 195016e..db49ace 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,14 @@ With no arguments, the current directory is linted. ### Configuration Every linting setting can be declared in the [config file](#config-file). -The four scalar settings additionally have a command-line flag, which +The five scalar settings additionally have a command-line flag, which overrides the config file when given: | Setting | Config member | Flag | | --- | --- | --- | | [Target platforms](#target-platforms) | `platforms` | `-platform` | | [Merge features](#merging) | `merge` | `-merge` | +| [Schema validation](#schema-validation) | `schema` | `-schema` | | [Deny warnings](#exit-codes) | `denyWarnings` | `-deny-warnings` | | [Output format](#output-formats) | `format` | `-format` | | [Category severities](#rule-categories) | `categories` | — | @@ -164,6 +165,39 @@ A few limits apply: - Registries are accessed anonymously, so a private image that cannot be pulled that way counts as a fetch failure. +### Schema validation + +Alongside the rules, decolint validates each file against the official +[Dev Container JSON Schema](https://containers.dev/implementors/json_reference/): +`devcontainer.json` and Feature (`devcontainer-feature.json`) files are +checked for the things the rules do not — misspelled or unknown property +names, wrong value types, and out-of-range enum values: + +``` +.devcontainer/devcontainer.json:8:3: error: unknown property "forwardPort"; did you mean "forwardPorts"? (schema-validation) +``` + +Schema findings are always reported as errors under the rule ID +`schema-validation`. They are not affected by category or rule severity +overrides, and [ignore comments](#suppressing-findings) do not suppress +them; choose the variant, or turn validation off entirely, instead: + +- `main` (default) — validates `devcontainer.json` against the schema + including the Visual Studio Code and GitHub Codespaces extensions, so + their properties (such as `customizations.vscode`) are accepted. +- `base` — validates against the specification's base schema only, so + editor-specific properties are reported as unknown. +- `off` — disables schema validation. + +```console +decolint -schema=base +``` + +The file is validated as written, before any [merging](#merging) or +variable substitution, so `${...}` placeholders in string values never +trip it. Templates are not schema-validated, but the `devcontainer.json` +a template ships is. + ### Output formats By default, findings are printed one per line, with the rule's @@ -252,8 +286,8 @@ interpolation reads (note the differing [syntax](#merging)): ``` The remaining members mirror their flags: `platforms`, `merge`, -`denyWarnings`, and `format` (see the [Configuration](#configuration) -table). +`schema`, `denyWarnings`, and `format` (see the +[Configuration](#configuration) table). For the strictest configuration, enable every category: diff --git a/cmd/decolint/config.go b/cmd/decolint/config.go index 85fc947..4d468e1 100644 --- a/cmd/decolint/config.go +++ b/cmd/decolint/config.go @@ -26,6 +26,10 @@ type Config struct { // Format selects how lint issues are written to stdout: "text" (the default when empty), "json", // "github", or "sarif". The -format flag takes precedence. Format string `json:"format"` + // Schema selects the schema-validation variant: "main" (the default when empty), "base", or + // "off". "main" allows the VS Code and Codespaces extensions to devcontainer.json, "base" + // rejects them, and "off" disables schema validation. The -schema flag takes precedence. + Schema string `json:"schema"` // LocalEnv maps names to the values "${localEnv:NAME}" (and "${env:NAME}") resolve to during // variable substitution, which runs with Merge; see [substitute.Context.LocalEnv]. It is also // the environment Compose-file "${...}" interpolation reads. Host environment variables are @@ -78,6 +82,14 @@ func (cfg Config) MarshalJSONTo(enc *jsontext.Encoder) error { return fmt.Errorf("encode config: %w", err) } } + if cfg.Schema != "" { + if err := enc.WriteToken(jsontext.String("schema")); err != nil { + return fmt.Errorf("encode config: %w", err) + } + if err := enc.WriteToken(jsontext.String(cfg.Schema)); err != nil { + return fmt.Errorf("encode config: %w", err) + } + } if len(cfg.LocalEnv) > 0 { if err := enc.WriteToken(jsontext.String("localEnv")); err != nil { return fmt.Errorf("encode config: %w", err) @@ -129,8 +141,8 @@ func writeSortedMap[V any](enc *jsontext.Encoder, m map[string]V) error { // -platform replaces the config file's Platforms (an empty -platform defers to the config file // rather than clearing it). -merge and -deny-warnings, when explicitly given, override Merge and // DenyWarnings in either direction (e.g. "-merge=false" disables merging even if the config file -// sets "merge": true). A non-empty -format replaces the config file's Format. LocalEnv, Categories, -// and Rules are config-file only. +// sets "merge": true). A non-empty -format replaces the config file's Format, and a non-empty +// -schema replaces its Schema. LocalEnv, Categories, and Rules are config-file only. func mergeConfig(opts Options, cfg Config) Config { if len(opts.Platforms) > 0 { cfg.Platforms = opts.Platforms @@ -144,6 +156,9 @@ func mergeConfig(opts Options, cfg Config) Config { if opts.Format != "" { cfg.Format = opts.Format } + if opts.Schema != "" { + cfg.Schema = opts.Schema + } return cfg } diff --git a/cmd/decolint/init.go b/cmd/decolint/init.go index f4a4c45..6214b30 100644 --- a/cmd/decolint/init.go +++ b/cmd/decolint/init.go @@ -46,6 +46,10 @@ func initConfigFile(output io.Writer) error { // "format" selects the output format ("text", "json", "github", or // "sarif"); the -format flag takes precedence, e.g.: // "format": "github" +// "schema" selects the Dev Container schema variant ("main", "base", or +// "off"); "base" rejects VS Code/Codespaces properties, "off" disables +// schema validation; the -schema flag takes precedence, e.g.: +// "schema": "base" // "localEnv" supplies the values ${localEnv:NAME} resolves to when merging, // and the environment Compose-file interpolation reads; environment // variables are never read, e.g.: diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index 4b352ea..eb22c68 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -12,6 +12,7 @@ import ( "os/signal" "path" "path/filepath" + "sort" "strings" "syscall" @@ -19,7 +20,9 @@ import ( "github.com/bare-devcontainer/decolint/feature" "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/rules" + "github.com/bare-devcontainer/decolint/schema" "github.com/bare-devcontainer/decolint/substitute" + "github.com/tailscale/hujson" ) // progName is the program name, used in the flag set, usage text, and error messages. @@ -106,7 +109,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } func versionString() string { - return fmt.Sprintf("%s %s (revision %s)", progName, version, revision) + return fmt.Sprintf("%s %s (revision %s, schema %s)", progName, version, revision, schema.Revision()) } // severityEmoji renders a severity for the -rules table. @@ -235,6 +238,15 @@ func runLint(ctx context.Context, stdout, stderr io.Writer, opts Options, cfg Co return false, err } + schemaName := cfg.Schema + if schemaName == "" { + schemaName = "main" + } + variant, err := schema.ParseVariant(schemaName) + if err != nil { + return false, fmt.Errorf("schema: %w", err) + } + threshold := failThreshold if cfg.DenyWarnings { threshold = linter.SeverityWarn @@ -280,7 +292,7 @@ func runLint(ctx context.Context, stdout, stderr io.Writer, opts Options, cfg Co } seen[dir] = struct{}{} - issues, err := lintPath(ctx, l, subst, merge, dir) + issues, err := lintPath(ctx, l, variant, subst, merge, dir) for _, issue := range issues { if issue.Severity > worstSeverity { worstSeverity = issue.Severity @@ -333,7 +345,7 @@ func (p absPath) String() string { // lintPath lints the devcontainer directory dir. It is opened as an os.Root, so every file the lint // reads is confined to it; because dir is absolute, so is every path derived from it. It is an error // if dir is not a directory. -func lintPath(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, dir absPath) ([]linter.Issue, error) { +func lintPath(ctx context.Context, l *linter.Linter, variant schema.Variant, subst substituteFn, merge mergeFn, dir absPath) ([]linter.Issue, error) { root, err := os.OpenRoot(string(dir)) if err != nil { // Pointing decolint straight at a devcontainer.json is a natural mistake, and "not a @@ -346,12 +358,12 @@ func lintPath(ctx context.Context, l *linter.Linter, subst substituteFn, merge m } // The root is only read from, so a close error is inconsequential. defer func() { _ = root.Close() }() - return lintDir(ctx, l, subst, merge, root) + return lintDir(ctx, l, variant, subst, merge, root) } // lintDir lints every configuration file in the devcontainer directory root is opened on. It is an // error if the directory contains no configuration. -func lintDir(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, root *os.Root) ([]linter.Issue, error) { +func lintDir(ctx context.Context, l *linter.Linter, variant schema.Variant, subst substituteFn, merge mergeFn, root *os.Root) ([]linter.Issue, error) { dir := absPath(root.Name()) if err := ctx.Err(); err != nil { return nil, fmt.Errorf("aborted %s: %w", dir, err) @@ -364,14 +376,16 @@ func lintDir(ctx context.Context, l *linter.Linter, subst substituteFn, merge me if err := ctx.Err(); err != nil { return fmt.Errorf("aborted %s: %w", configPath(f), err) } - fileIssues, err := lintFile(ctx, l, subst, merge, f, dir) + fileIssues, err := lintFile(ctx, l, variant, subst, merge, f, dir) + // fileIssues may hold schema-validation findings even when err is non-nil (e.g. a merge + // failure after the file itself validated), so collect them before handling the error. + issues = append(issues, fileIssues...) if err != nil { // A broken file must not stop the remaining files from being linted, so record the // error and keep visiting. errs = append(errs, err) return nil } - issues = append(issues, fileIssues...) return nil }) if err != nil { @@ -394,7 +408,9 @@ func configPath(f discovery.ConfigFile) absPath { // resolves variables in dev container configurations before merge and rules run, so both see // resolved values. merge, if non-nil, runs on dev container configurations before rules and is // skipped when no rule applies to f's type, so a file with no active rules does no Feature fetches. -func lintFile(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, f discovery.ConfigFile, dir absPath) ([]linter.Issue, error) { +// Schema validation, unless variant is off, runs on the file as written, before substitution and +// merging. +func lintFile(ctx context.Context, l *linter.Linter, variant schema.Variant, subst substituteFn, merge mergeFn, f discovery.ConfigFile, dir absPath) ([]linter.Issue, error) { location := configPath(f) src, err := f.Root.ReadFile(f.Path) if err != nil { @@ -404,6 +420,12 @@ func lintFile(ctx context.Context, l *linter.Linter, subst substituteFn, merge m if err != nil { return nil, fmt.Errorf("%s: %w", location, err) } + // Schema validation reads the document as authored: variable substitution and Feature merging + // mutate the tree, so both run only afterward. + schemaIssues, err := validateSchema(variant, f, location, src, doc) + if err != nil { + return nil, err + } if subst != nil && f.Type == linter.Devcontainer { // The linted directory is the workspace folder the real tooling would mount, even for a // configuration discovered in a sub-root like .devcontainer. @@ -411,17 +433,86 @@ func lintFile(ctx context.Context, l *linter.Linter, subst substituteFn, merge m } if merge != nil && f.Type == linter.Devcontainer && l.HasRules(f.Type) { if err := merge(ctx, f, doc); err != nil { - return nil, fmt.Errorf("merge features %s: %w", location, err) + // Schema validation already ran on the file as written, so its findings are valid + // regardless of the merge outcome; return them alongside the error rather than dropping + // them. + return schemaIssues, fmt.Errorf("merge features %s: %w", location, err) } } // Give rules read access to the config file's directory, confined to the discovery root so // resolution cannot escape it (see linter.Dir). sub, err := fs.Sub(f.Root.FS(), path.Dir(filepath.ToSlash(f.Path))) if err != nil { - return nil, fmt.Errorf("open config dir %s: %w", location, err) + return schemaIssues, fmt.Errorf("open config dir %s: %w", location, err) } // The reported path can be relative to the containing directory itself, naming no directory at // all, so the name comes from the absolute location. name := filepath.Base(filepath.Dir(string(location))) - return l.LintDocument(location.String(), f.Type, doc, linter.Dir{FS: sub, Name: name}), nil + issues := append(schemaIssues, l.LintDocument(location.String(), f.Type, doc, linter.Dir{FS: sub, Name: name})...) + sortIssues(issues) + return issues, nil +} + +// schemaRuleID is the diagnostic ID reported for schema-validation findings. Unlike rule findings, +// these are independent of the rule engine: always errors, not configurable via the config file's +// "rules"/"categories" members, and not suppressible via ignore comments. Only "schema": "off" +// disables them. +const schemaRuleID = "schema-validation" + +// validateSchema validates f against the selected schema variant and returns its findings as issues. +// It returns nil for templates, which have no official schema, and for the off variant. +func validateSchema(variant schema.Variant, f discovery.ConfigFile, location absPath, src []byte, doc *linter.Document) ([]linter.Issue, error) { + kind, ok := schemaKind(f.Type) + if !ok { + return nil, nil + } + std, err := hujson.Standardize(src) + if err != nil { + return nil, fmt.Errorf("standardize %s: %w", location, err) + } + diags, err := schema.Validate(variant, kind, std, doc.OffsetAt) + if err != nil { + return nil, fmt.Errorf("schema validation %s: %w", location, err) + } + issues := make([]linter.Issue, 0, len(diags)) + for _, d := range diags { + line, col := doc.Position(d.Offset) + issues = append(issues, linter.Issue{ + Path: location.String(), + Line: line, + Col: col, + RuleID: schemaRuleID, + Message: d.Message, + Severity: linter.SeverityError, + }) + } + return issues, nil +} + +// schemaKind maps a file type to the schema kind that validates it. Templates have no official +// schema, so they map to false. +func schemaKind(t linter.FileType) (schema.Kind, bool) { + switch t { + case linter.Devcontainer: + return schema.KindDevcontainer, true + case linter.Feature: + return schema.KindFeature, true + default: + return 0, false + } +} + +// sortIssues orders issues by position, then rule ID, so schema-validation findings interleave with +// rule findings the way LintDocument already orders its own. +func sortIssues(issues []linter.Issue) { + sort.Slice(issues, func(i, j int) bool { + a, b := issues[i], issues[j] + if a.Line != b.Line { + return a.Line < b.Line + } + if a.Col != b.Col { + return a.Col < b.Col + } + return a.RuleID < b.RuleID + }) } diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index 7b74891..e4a5adf 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -18,6 +18,7 @@ import ( "github.com/bare-devcontainer/decolint/linter" "github.com/bare-devcontainer/decolint/ocitest" "github.com/bare-devcontainer/decolint/rules" + "github.com/bare-devcontainer/decolint/schema" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) @@ -204,7 +205,9 @@ func TestRun(t *testing.T) { }, { // A Template directory is linted both for its devcontainer-template.json and for the dev - // container configuration it bundles, so findings appear at both files. + // container configuration it bundles, so findings appear at both files. The bundled + // configuration defines no container source, which trips both the missing-container-def + // rule and schema validation (the schema's container branches all require a source). name: "template directory", args: []string{"testdata/e2e/template"}, want: []firing{ @@ -213,6 +216,7 @@ func TestRun(t *testing.T) { // unused-template-option is a style rule, off by default, so it does not fire here even // though the fixture declares an unused option. {templateBundledFile, "missing-container-def", linter.SeverityError}, + {templateBundledFile, "schema-validation", linter.SeverityError}, }, wantExitCode: 1, }, @@ -320,6 +324,89 @@ func TestRun_ReportedPathsAreWorkingDirectoryRelative(t *testing.T) { }) } +// TestRun_Schema exercises the schema-validation pass through run(): its default-on behavior, the +// variant selection, and that it is neither configurable via rule severities nor suppressible. +func TestRun_Schema(t *testing.T) { + t.Parallel() + + // schemaFindings runs decolint with the given extra args and returns the schema-validation + // messages reported for the single devcontainer.json body. + schemaFindings := func(t *testing.T, body string, extra ...string) []string { + t.Helper() + dir := writeDevcontainer(t, body) + args := append([]string{"-format=json"}, extra...) + args = append(args, dir) + var stdout, stderr bytes.Buffer + run(t.Context(), args, &stdout, &stderr) + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var issues []linter.Issue + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + t.Fatalf("output is not a JSON issue array: %v\noutput: %s", err, stdout.String()) + } + var msgs []string + for _, issue := range issues { + if issue.RuleID == "schema-validation" { + msgs = append(msgs, issue.Message) + } + } + return msgs + } + + t.Run("default main reports a misspelled property", func(t *testing.T) { + t.Parallel() + got := schemaFindings(t, `{"image": "ubuntu", "forwardPort": [3000]}`) + want := []string{`unknown property "forwardPort"; did you mean "forwardPorts"?`} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("schema findings mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("-schema=off disables validation", func(t *testing.T) { + t.Parallel() + if got := schemaFindings(t, `{"image": "ubuntu", "forwardPort": [3000]}`, "-schema=off"); got != nil { + t.Errorf("schema findings = %v, want none", got) + } + }) + + t.Run("base rejects a VS Code property that main accepts", func(t *testing.T) { + t.Parallel() + body := `{"image": "ubuntu", "extensions": []}` + if got := schemaFindings(t, body, "-schema=main"); got != nil { + t.Errorf("main schema findings = %v, want none", got) + } + want := []string{`unknown property "extensions"`} + if got := schemaFindings(t, body, "-schema=base"); cmp.Diff(want, got) != "" { + t.Errorf("base schema findings = %v, want %v", got, want) + } + }) + + t.Run("an invalid variant is a usage error", func(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + exitCode := run(t.Context(), []string{"-schema=bogus", "."}, &stdout, &stderr) + if exitCode != exitCodeError { + t.Errorf("exit code = %d, want %d", exitCode, exitCodeError) + } + if !strings.Contains(stderr.String(), `unknown variant "bogus"`) { + t.Errorf("stderr = %q, want it to name the invalid schema variant", stderr.String()) + } + }) + + t.Run("ignore comments do not suppress schema findings", func(t *testing.T) { + t.Parallel() + // A file-wide ignore directive silences rules but must not reach schema validation. + body := "{\n" + + ` // decolint-ignore-file` + "\n" + + ` "image": "ubuntu",` + "\n" + + ` "forwardPort": [3000]` + "\n}" + if got := schemaFindings(t, body); len(got) != 1 { + t.Errorf("schema findings = %v, want the unknown-property finding to survive the ignore directive", got) + } + }) +} + func TestRun_Flags(t *testing.T) { t.Parallel() @@ -337,6 +424,9 @@ func TestRun_Flags(t *testing.T) { if !strings.Contains(stdout.String(), "decolint") { t.Errorf("stdout = %q, want it to mention decolint", stdout.String()) } + if !strings.Contains(stdout.String(), "schema") { + t.Errorf("stdout = %q, want it to report the schema revision", stdout.String()) + } }) t.Run("-rules", func(t *testing.T) { @@ -1370,7 +1460,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d "devcontainer.metadata": `[{"privileged": true, "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"]}]`, }, false) - dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app"}`) + dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspace"}`) writeComposeFile(t, dir, fmt.Sprintf("services:\n app:\n image: %s/base:1\n", host)) var stdout, stderr bytes.Buffer @@ -1397,7 +1487,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d // The Compose file names its image through "${IMAGE}", which resolves only from the config's // localEnv map: without the localEnv threaded into merge it would interpolate to empty and // contribute nothing, so this run also proves the wiring. - dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app"}`) + dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspace"}`) writeComposeFile(t, dir, "services:\n app:\n image: ${IMAGE}\n") config := filepath.Join(t.TempDir(), "decolint.jsonc") body := fmt.Sprintf(`{"rules": {"no-privileged-container": "error"}, "localEnv": {"IMAGE": %q}}`, host+"/base:1") @@ -1421,7 +1511,8 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d body := "{\n" + ` "dockerComposeFile": "docker-compose.yml",` + "\n" + - ` "service": "app"` + "\n}" + ` "service": "app",` + "\n" + + ` "workspaceFolder": "/workspace"` + "\n}" dir := writeDevcontainer(t, body) writeComposeFile(t, dir, "services:\n app:\n build:\n context: .\n") writeDockerfile(t, dir, `FROM scratch @@ -1456,7 +1547,7 @@ LABEL devcontainer.metadata='[{"privileged": true, "mounts": ["source=/var/run/d t.Parallel() // The service names an image that never resolves, so fetching its metadata fails and the // failure must surface as exit code 2 rather than a lint result. - dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app"}`) + dir := writeDevcontainer(t, `{"dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspace"}`) writeComposeFile(t, dir, "services:\n app:\n image: registry.invalid/base:1\n") var stdout, stderr bytes.Buffer @@ -1503,7 +1594,7 @@ func TestLintPath(t *testing.T) { t.Fatal(err) } - issues, err := lintPath(t.Context(), newLinter(), noSubst, nil, absPath(dir)) + issues, err := lintPath(t.Context(), newLinter(), schema.VariantOff, noSubst, nil, absPath(dir)) if err != nil { t.Fatalf("lintPath: %v", err) } @@ -1535,7 +1626,7 @@ func TestLintPath(t *testing.T) { t.Fatal(err) } defer func() { _ = root.Close() }() - issues, err := lintDir(t.Context(), newLinter(), noSubst, nil, root) + issues, err := lintDir(t.Context(), newLinter(), schema.VariantOff, noSubst, nil, root) if err == nil { t.Fatal("got nil error, want a parse error for the broken config") } @@ -1550,7 +1641,7 @@ func TestLintPath(t *testing.T) { t.Run("directory without config", func(t *testing.T) { t.Parallel() - _, err := lintPath(t.Context(), newLinter(), noSubst, nil, absPath(t.TempDir())) + _, err := lintPath(t.Context(), newLinter(), schema.VariantOff, noSubst, nil, absPath(t.TempDir())) if err == nil || !strings.Contains(err.Error(), "no devcontainer configuration found") { t.Errorf("err = %v, want 'no devcontainer configuration found'", err) } @@ -1559,7 +1650,7 @@ func TestLintPath(t *testing.T) { t.Run("a configuration file is not a directory", func(t *testing.T) { t.Parallel() file := filepath.Join(writeDevcontainer(t, body), ".devcontainer", "devcontainer.json") - _, err := lintPath(t.Context(), newLinter(), noSubst, nil, absPath(file)) + _, err := lintPath(t.Context(), newLinter(), schema.VariantOff, noSubst, nil, absPath(file)) if err == nil || !strings.Contains(err.Error(), "is not a directory; pass the directory") { t.Errorf("err = %v, want the error to say a directory is expected", err) } @@ -1571,7 +1662,7 @@ func TestLintPath(t *testing.T) { wantErr := errors.New("fetch failed") merge := func(context.Context, discovery.ConfigFile, *linter.Document) error { return wantErr } - if _, err := lintPath(t.Context(), newLinter(), noSubst, merge, absPath(dir)); !errors.Is(err, wantErr) { + if _, err := lintPath(t.Context(), newLinter(), schema.VariantOff, noSubst, merge, absPath(dir)); !errors.Is(err, wantErr) { t.Errorf("lintPath error = %v, want %v", err, wantErr) } }) @@ -1585,7 +1676,7 @@ func TestLintPath(t *testing.T) { return nil } - if _, err := lintPath(t.Context(), linter.New(), noSubst, merge, absPath(dir)); err != nil { + if _, err := lintPath(t.Context(), linter.New(), schema.VariantOff, noSubst, merge, absPath(dir)); err != nil { t.Fatalf("lintPath: %v", err) } if called { @@ -1615,7 +1706,7 @@ func TestLintPath(t *testing.T) { } subst := func(string, *linter.Document) {} - if _, err := lintPath(t.Context(), l, subst, merge, absPath(dir)); err != nil { + if _, err := lintPath(t.Context(), l, schema.VariantOff, subst, merge, absPath(dir)); err != nil { t.Fatalf("lintPath: %v", err) } if called { @@ -1677,7 +1768,7 @@ func TestLintPath_DirName(t *testing.T) { l := linter.New() l.RegisterRule(dirNameRule, linter.SeverityWarn) - issues, err := lintPath(t.Context(), l, nil, nil, absPath(target)) + issues, err := lintPath(t.Context(), l, schema.VariantOff, nil, nil, absPath(target)) if err != nil { t.Fatalf("lintPath: %v", err) } diff --git a/cmd/decolint/opts.go b/cmd/decolint/opts.go index 4e71840..06bd4ba 100644 --- a/cmd/decolint/opts.go +++ b/cmd/decolint/opts.go @@ -40,6 +40,10 @@ type Options struct { // stdout: "text", "json", "github", or "sarif". A non-empty value replaces the config file's // "format" member; it is resolved into a Format by parseFormat in runLint. Format string + // Schema is the raw -schema flag value ("" if not given), naming the schema-validation variant: + // "base", "main", or "off". A non-empty value replaces the config file's "schema" member; it is + // resolved into a schema.Variant in runLint. + Schema string // Version, when set, causes the program to print its version and exit. Version bool // ListRules, when set, causes the program to print the built-in rules and exit. @@ -54,6 +58,7 @@ func parseOptions(args []string, output io.Writer) (Options, error) { var opts Options var platformFlag string var formatFlag string + var schemaFlag string fs := flag.NewFlagSet(progName, flag.ContinueOnError) fs.SetOutput(output) @@ -61,6 +66,7 @@ func parseOptions(args []string, output io.Writer) (Options, error) { fs.StringVar(&opts.ConfigPath, "config", "", "path to a config file (default: auto-discover .decolint.jsonc or .decolint.json in the current directory)") fs.StringVar(&platformFlag, "platform", "", "comma-separated target platforms to include in addition to \"all\" (vscode, codespaces); overrides the config file's \"platforms\" member") fs.StringVar(&formatFlag, "format", "", "output format: text (default), json, github, or sarif; overrides the config file's \"format\" member") + fs.StringVar(&schemaFlag, "schema", "", "validate against the official Dev Container schema: main (default), base, or off; overrides the config file's \"schema\" member") fs.BoolVar(&opts.Merge, "merge", false, "lint the merged (effective) configuration, including referenced Features and base image metadata; overrides the config file's \"merge\" member") fs.BoolVar(&opts.Version, "version", false, "print version information and exit") fs.BoolVar(&opts.ListRules, "rules", false, "print the built-in rules as a Markdown table (category, target platforms, current severity), then exit") @@ -88,6 +94,10 @@ func parseOptions(args []string, output io.Writer) (Options, error) { // config file's "format" member is merged in, so both sources go through one validation path. opts.Format = formatFlag + // Likewise, the raw name is resolved into a schema.Variant in runLint after the config file's + // "schema" member is merged in, so both sources go through one validation path. + opts.Schema = schemaFlag + opts.Paths = fs.Args() if len(opts.Paths) == 0 { opts.Paths = []string{"."} diff --git a/cmd/updateschemas/main.go b/cmd/updateschemas/main.go new file mode 100644 index 0000000..c041255 --- /dev/null +++ b/cmd/updateschemas/main.go @@ -0,0 +1,163 @@ +// Command updateschemas refreshes the vendored Dev Container schemas in schema/data from upstream. +// +// It resolves the current tip of devcontainers/spec@main and microsoft/vscode@main and downloads the +// schema files at those commits. When none of them differs from what is vendored it writes nothing; +// otherwise it rewrites schema/data/*.json and schema/data/REVISIONS.json. The schema-sync workflow +// runs it and opens a pull request when the working tree comes back dirty. +// +// Run it from the repository root: go run ./cmd/updateschemas. +package main + +import ( + "bytes" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + specRepo = "https://github.com/devcontainers/spec.git" + vscodeRepo = "https://github.com/microsoft/vscode.git" + dataDir = "schema/data" +) + +// file describes one vendored schema: its local name and the upstream path, relative to a commit, it +// is fetched from. +type file struct { + name string + repo string // "spec" or "vscode" + upPath string +} + +var files = []file{ + {"devContainer.base.schema.json", "spec", "schemas/devContainer.base.schema.json"}, + {"devContainer.schema.json", "spec", "schemas/devContainer.schema.json"}, + {"devContainerFeature.schema.json", "spec", "schemas/devContainerFeature.schema.json"}, + {"devContainer.codespaces.schema.json", "vscode", "extensions/configuration-editing/schemas/devContainer.codespaces.schema.json"}, + {"devContainer.vscode.schema.json", "vscode", "extensions/configuration-editing/schemas/devContainer.vscode.schema.json"}, +} + +// revisions mirrors schema/data/REVISIONS.json. +type revisions struct { + Spec string `json:"spec"` + VSCode string `json:"vscode"` + Sources map[string]string `json:"sources"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "updateschemas:", err) + os.Exit(1) + } +} + +func run() error { + specSHA, err := headSHA(specRepo) + if err != nil { + return err + } + vscodeSHA, err := headSHA(vscodeRepo) + if err != nil { + return err + } + sha := map[string]string{"spec": specSHA, "vscode": vscodeSHA} + + rev := revisions{Spec: specSHA, VSCode: vscodeSHA, Sources: map[string]string{}} + fetched := make(map[string][]byte, len(files)) + changed := false + for _, f := range files { + url := rawURL(f.repo, sha[f.repo], f.upPath) + body, err := download(url) + if err != nil { + return err + } + current, err := os.ReadFile(filepath.Join(dataDir, f.name)) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", f.name, err) + } + if !bytes.Equal(current, body) { + changed = true + } + fetched[f.name] = body + rev.Sources[f.name] = url + } + + // Both upstreams receive commits that leave the schemas untouched — microsoft/vscode many times + // a day — so recording every new commit would rewrite REVISIONS.json on almost every run and + // have the sync workflow raise a pull request that changes no schema. Nothing is written unless + // a schema itself differs; the recorded commits then still name where the current contents came + // from. + if !changed { + return nil + } + + for _, f := range files { + if err := os.WriteFile(filepath.Join(dataDir, f.name), fetched[f.name], 0o644); err != nil { + return fmt.Errorf("write %s: %w", f.name, err) + } + } + return writeRevisions(dataDir, rev) +} + +// headSHA returns the commit hash at the tip of the repository's main branch. +func headSHA(repo string) (string, error) { + out, err := exec.Command("git", "ls-remote", repo, "refs/heads/main").Output() + if err != nil { + return "", fmt.Errorf("resolve %s main: %w", repo, err) + } + sha, _, ok := strings.Cut(strings.TrimSpace(string(out)), "\t") + if !ok || sha == "" { + return "", fmt.Errorf("resolve %s main: unexpected ls-remote output %q", repo, out) + } + return sha, nil +} + +func rawURL(repo, sha, path string) string { + owner := "devcontainers/spec" + if repo == "vscode" { + owner = "microsoft/vscode" + } + return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", owner, sha, path) +} + +func download(url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("get %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get %s: status %s", url, resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s: %w", url, err) + } + if len(body) > 0 && body[len(body)-1] != '\n' { + body = append(body, '\n') + } + return body, nil +} + +// writeRevisions writes rev to REVISIONS.json under dir with two-space indentation and a trailing +// newline, matching the committed format. The sources are marshaled deterministically: map order is +// otherwise unspecified, which would reorder the file on every run and make it look changed. +func writeRevisions(dir string, rev revisions) error { + b, err := json.Marshal(rev, jsontext.Multiline(true), jsontext.WithIndent(" "), json.Deterministic(true)) + if err != nil { + return fmt.Errorf("marshal revisions: %w", err) + } + b = append(b, '\n') + if err := os.WriteFile(filepath.Join(dir, "REVISIONS.json"), b, 0o644); err != nil { + return fmt.Errorf("write REVISIONS.json: %w", err) + } + return nil +} diff --git a/cmd/updateschemas/main_test.go b/cmd/updateschemas/main_test.go new file mode 100644 index 0000000..3e1238c --- /dev/null +++ b/cmd/updateschemas/main_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestRawURL(t *testing.T) { + t.Parallel() + + tests := []struct { + repo string + want string + }{ + {"spec", "https://raw.githubusercontent.com/devcontainers/spec/abc123/schemas/x.json"}, + {"vscode", "https://raw.githubusercontent.com/microsoft/vscode/abc123/schemas/x.json"}, + } + for _, tt := range tests { + t.Run(tt.repo, func(t *testing.T) { + t.Parallel() + if got := rawURL(tt.repo, "abc123", "schemas/x.json"); got != tt.want { + t.Errorf("rawURL(%q) = %q, want %q", tt.repo, got, tt.want) + } + }) + } +} + +func TestWriteRevisions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rev := revisions{ + Spec: "spec-sha", + VSCode: "vscode-sha", + Sources: map[string]string{"devContainer.schema.json": "https://example.invalid/s.json"}, + } + if err := writeRevisions(dir, rev); err != nil { + t.Fatalf("writeRevisions: %v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "REVISIONS.json")) + if err != nil { + t.Fatalf("read: %v", err) + } + got := string(b) + for _, want := range []string{`"spec": "spec-sha"`, `"vscode": "vscode-sha"`, "https://example.invalid/s.json"} { + if !strings.Contains(got, want) { + t.Errorf("REVISIONS.json missing %q; got:\n%s", want, got) + } + } + if !strings.HasSuffix(got, "}\n") { + t.Errorf("REVISIONS.json should end with a newline; got:\n%s", got) + } +} + +// TestWriteRevisions_Deterministic checks that the same revisions always marshal to the same bytes. +// The sources are a map, whose order is unspecified, so without pinning it the file would be +// rewritten in a new order on every run and the sync workflow would raise a pull request that +// changes no schema. +func TestWriteRevisions_Deterministic(t *testing.T) { + t.Parallel() + + rev := revisions{ + Spec: "spec-sha", + VSCode: "vscode-sha", + Sources: map[string]string{ + "devContainer.base.schema.json": "https://example.invalid/base.json", + "devContainer.schema.json": "https://example.invalid/main.json", + "devContainerFeature.schema.json": "https://example.invalid/feature.json", + "devContainer.codespaces.schema.json": "https://example.invalid/codespaces.json", + "devContainer.vscode.schema.json": "https://example.invalid/vscode.json", + }, + } + // One write per iteration, each into a fresh directory, so any dependence on map iteration order + // shows up as a differing result. + var first string + for i := range 10 { + dir := t.TempDir() + if err := writeRevisions(dir, rev); err != nil { + t.Fatalf("writeRevisions: %v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "REVISIONS.json")) + if err != nil { + t.Fatalf("read: %v", err) + } + if i == 0 { + first = string(b) + continue + } + if diff := cmp.Diff(first, string(b)); diff != "" { + t.Fatalf("run %d differs from the first (-first +got):\n%s", i, diff) + } + } +} diff --git a/go.mod b/go.mod index 9af023b..4b4acce 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,20 @@ module github.com/bare-devcontainer/decolint go 1.26.5 require ( + github.com/agext/levenshtein v1.2.3 github.com/compose-spec/compose-go/v2 v2.13.0 github.com/google/go-cmp v0.7.0 github.com/moby/buildkit v0.31.2 github.com/olareg/olareg v0.2.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd + golang.org/x/text v0.38.0 oras.land/oras-go/v2 v2.6.2 ) require ( - github.com/agext/levenshtein v1.2.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/containerd/v2 v2.2.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -43,7 +45,6 @@ require ( github.com/moby/sys/signal v0.7.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -65,7 +66,6 @@ require ( golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 58f1e9d..90a8f41 100644 --- a/go.sum +++ b/go.sum @@ -143,8 +143,6 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/linter/document.go b/linter/document.go index cff5793..04bf04d 100644 --- a/linter/document.go +++ b/linter/document.go @@ -2,6 +2,7 @@ package linter import ( "fmt" + "strconv" "github.com/tailscale/hujson" ) @@ -33,3 +34,48 @@ func ParseDocument(src []byte) (*Document, error) { // carry offsets pointing into the original source, since findings are still positioned against // that source. Ignore directives are read at parse time and unaffected by later mutation. func (d *Document) Tree() *hujson.Value { return d.tree } + +// Position converts a byte offset into the source into a 1-based line and column (the column in +// bytes). It positions findings that originate outside the rule walk, such as schema-validation +// diagnostics, against the same source the rules report against. +func (d *Document) Position(offset int) (line, col int) { + return d.pos.lineCol(offset) +} + +// OffsetAt returns the byte offset of the tree node at the given instance location, expressed as +// unescaped JSON Pointer segments (object member names, or array indices in decimal). It resolves +// the location the same way [walk] builds one, following object members by name and array elements +// by index. When a segment cannot be resolved — the location points into a value that is not an +// object or array, or names a missing member — it returns the offset of the deepest ancestor it did +// resolve, so a diagnostic is never left unpositioned. The empty location yields the document root's +// offset. It reads the tree as given; any mutation (see [Document.Tree]) must happen after. +func (d *Document) OffsetAt(loc []string) int { + v := d.tree + for _, seg := range loc { + next := childValue(v, seg) + if next == nil { + break + } + v = next + } + return v.StartOffset +} + +// childValue returns the member named seg of an object, or the element at index seg of an array, or +// nil when v is neither or the segment does not resolve. +func childValue(v *hujson.Value, seg string) *hujson.Value { + switch t := v.Value.(type) { + case *hujson.Object: + for i := range t.Members { + name, ok := t.Members[i].Name.Value.(hujson.Literal) + if ok && name.String() == seg { + return &t.Members[i].Value + } + } + case *hujson.Array: + if idx, err := strconv.Atoi(seg); err == nil && idx >= 0 && idx < len(t.Elements) { + return &t.Elements[idx] + } + } + return nil +} diff --git a/linter/document_test.go b/linter/document_test.go new file mode 100644 index 0000000..8610e95 --- /dev/null +++ b/linter/document_test.go @@ -0,0 +1,62 @@ +package linter + +import "testing" + +func TestDocument_OffsetAt(t *testing.T) { + t.Parallel() + + src := `{ + "image": "ubuntu:24.04", + "forwardPorts": [3000, 8080], + "customizations": { + "vscode": { + "extensions": ["golang.go"] + } + } +}` + doc, err := ParseDocument([]byte(src)) + if err != nil { + t.Fatalf("ParseDocument: %v", err) + } + + tests := []struct { + name string + loc []string + wantLine int + wantByte byte // first source byte at the returned offset, to anchor the assertion + }{ + {"root", nil, 1, '{'}, + {"top-level property value", []string{"image"}, 2, '"'}, + {"array element", []string{"forwardPorts", "1"}, 3, '8'}, + {"nested object", []string{"customizations", "vscode"}, 5, '{'}, + {"nested array element", []string{"customizations", "vscode", "extensions", "0"}, 6, '"'}, + {"missing member falls back to parent", []string{"customizations", "absent"}, 4, '{'}, + {"non-container segment falls back", []string{"image", "deeper"}, 2, '"'}, + {"out-of-range index falls back", []string{"forwardPorts", "9"}, 3, '['}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + off := doc.OffsetAt(tt.loc) + if got := src[off]; got != tt.wantByte { + t.Errorf("OffsetAt(%v) offset %d points at %q, want %q", tt.loc, off, got, tt.wantByte) + } + if line, _ := doc.Position(off); line != tt.wantLine { + t.Errorf("Position(OffsetAt(%v)) line = %d, want %d", tt.loc, line, tt.wantLine) + } + }) + } +} + +func TestDocument_Position(t *testing.T) { + t.Parallel() + + doc, err := ParseDocument([]byte("{\n \"a\": 1\n}")) + if err != nil { + t.Fatalf("ParseDocument: %v", err) + } + // The "a" key starts at byte 4 (after "{\n "), on line 2, column 3. + if line, col := doc.Position(4); line != 2 || col != 3 { + t.Errorf("Position(4) = (%d, %d), want (2, 3)", line, col) + } +} diff --git a/schema/compile.go b/schema/compile.go new file mode 100644 index 0000000..1a0efa6 --- /dev/null +++ b/schema/compile.go @@ -0,0 +1,109 @@ +package schema + +import ( + "bytes" + "embed" + "fmt" + "sync" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// data holds the vendored official Dev Container schemas. They are refreshed by "make +// update-schemas"; the upstream commits they were taken from are recorded in data/REVISIONS.json +// and reported by [Revision]. +// +//go:embed data +var data embed.FS + +// Canonical URLs the schemas are registered under. They must match the $ref targets used inside the +// schemas verbatim: devContainer.schema.json refers to the base schema relatively and to the two +// VS Code schemas by these absolute URLs, so registering the vendored copies here lets compilation +// resolve every reference offline. +const ( + urlBase = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json" + urlMain = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json" + urlFeature = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainerFeature.schema.json" + urlCodespaces = "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/configuration-editing/schemas/devContainer.codespaces.schema.json" + urlVSCode = "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/configuration-editing/schemas/devContainer.vscode.schema.json" +) + +// vscodeInternalRefs are $ref targets in the VS Code schema that address editor-internal resources +// (machine settings, MCP configuration). They are unreachable outside VS Code, so each is registered +// as a permissive stub: decolint validates the devcontainer structure, not the VS Code settings blob. +var vscodeInternalRefs = []string{ + "vscode://schemas/settings/machine", + "vscode://schemas/mcp", +} + +// compiled caches one compiled schema per entry URL; compilation is deterministic and the schemas +// are read-only, so a single package-wide cache is safe for concurrent use. +var ( + compiledMu sync.Mutex + compiled = map[string]*jsonschema.Schema{} +) + +// compile returns the schema compiled from the vendored resource registered under entryURL. +func compile(entryURL string) (*jsonschema.Schema, error) { + compiledMu.Lock() + defer compiledMu.Unlock() + if s, ok := compiled[entryURL]; ok { + return s, nil + } + + c := jsonschema.NewCompiler() + // Every reference resolves to a registered resource, so no loader ever runs. Installing one that + // fails loudly turns a forgotten vendored dependency (e.g. a newly added upstream $ref) into a + // clear compile error instead of a silent network fetch. + c.UseLoader(errorLoader{}) + + resources := map[string]string{ + urlBase: "data/devContainer.base.schema.json", + urlMain: "data/devContainer.schema.json", + urlFeature: "data/devContainerFeature.schema.json", + urlCodespaces: "data/devContainer.codespaces.schema.json", + urlVSCode: "data/devContainer.vscode.schema.json", + } + for url, path := range resources { + doc, err := readJSON(path) + if err != nil { + return nil, err + } + if err := c.AddResource(url, doc); err != nil { + return nil, fmt.Errorf("add schema resource %s: %w", url, err) + } + } + for _, url := range vscodeInternalRefs { + if err := c.AddResource(url, map[string]any{}); err != nil { + return nil, fmt.Errorf("add stub resource %s: %w", url, err) + } + } + + s, err := c.Compile(entryURL) + if err != nil { + return nil, fmt.Errorf("compile schema %s: %w", entryURL, err) + } + compiled[entryURL] = s + return s, nil +} + +// errorLoader rejects any attempt to load a schema that was not pre-registered, keeping validation +// fully offline. +type errorLoader struct{} + +func (errorLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("schema %s is not vendored; run \"make update-schemas\"", url) +} + +// readJSON decodes an embedded schema file into the value shape the validator expects. +func readJSON(path string) (any, error) { + b, err := data.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read embedded schema %s: %w", path, err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("decode embedded schema %s: %w", path, err) + } + return doc, nil +} diff --git a/schema/data/REVISIONS.json b/schema/data/REVISIONS.json new file mode 100644 index 0000000..ecc6c04 --- /dev/null +++ b/schema/data/REVISIONS.json @@ -0,0 +1,11 @@ +{ + "spec": "c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421", + "vscode": "dd862885ff5fbd279747c793e18105e6b7ddc805", + "sources": { + "devContainer.base.schema.json": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainer.base.schema.json", + "devContainer.codespaces.schema.json": "https://raw.githubusercontent.com/microsoft/vscode/dd862885ff5fbd279747c793e18105e6b7ddc805/extensions/configuration-editing/schemas/devContainer.codespaces.schema.json", + "devContainer.schema.json": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainer.schema.json", + "devContainer.vscode.schema.json": "https://raw.githubusercontent.com/microsoft/vscode/dd862885ff5fbd279747c793e18105e6b7ddc805/extensions/configuration-editing/schemas/devContainer.vscode.schema.json", + "devContainerFeature.schema.json": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainerFeature.schema.json" + } +} diff --git a/schema/data/devContainer.base.schema.json b/schema/data/devContainer.base.schema.json new file mode 100644 index 0000000..86709ec --- /dev/null +++ b/schema/data/devContainer.base.schema.json @@ -0,0 +1,771 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "description": "Defines a dev container", + "allowComments": true, + "allowTrailingCommas": false, + "definitions": { + "devContainerCommon": { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "The JSON schema of the `devcontainer.json` file." + }, + "name": { + "type": "string", + "description": "A name for the dev container which can be displayed to the user." + }, + "features": { + "type": "object", + "description": "Features to add to the dev container.", + "properties": { + "fish": { + "deprecated": true, + "deprecationMessage": "Legacy feature not supported. Please check https://containers.dev/features for replacements." + }, + "maven": { + "deprecated": true, + "deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/java` has an option to install Maven." + }, + "gradle": { + "deprecated": true, + "deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/java` has an option to install Gradle." + }, + "homebrew": { + "deprecated": true, + "deprecationMessage": "Legacy feature not supported. Please check https://containers.dev/features for replacements." + }, + "jupyterlab": { + "deprecated": true, + "deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/python` has an option to install JupyterLab." + } + }, + "additionalProperties": true + }, + "overrideFeatureInstallOrder": { + "type": "array", + "description": "Array consisting of the Feature id (without the semantic version) of Features in the order the user wants them to be installed.", + "items": { + "type": "string" + } + }, + "secrets": { + "type": "object", + "description": "Recommended secrets for this dev container. Recommendations are provided as environment variable keys with optional metadata.", + "patternProperties": { + "^[a-zA-Z_][a-zA-Z0-9_]*$": { + "type": "object", + "description": "Environment variable keys following unix-style naming conventions. eg: ^[a-zA-Z_][a-zA-Z0-9_]*$", + "properties": { + "description": { + "type": "string", + "description": "A description of the secret." + }, + "documentationUrl": { + "type": "string", + "format": "uri", + "description": "A URL to documentation about the secret." + } + }, + "additionalProperties": false + }, + "additionalProperties": false + }, + "additionalProperties": false + }, + "forwardPorts": { + "type": "array", + "description": "Ports that are forwarded from the container to the local machine. Can be an integer port number, or a string of the format \"host:port_number\".", + "items": { + "oneOf": [ + { + "type": "integer", + "maximum": 65535, + "minimum": 0 + }, + { + "type": "string", + "pattern": "^([a-z0-9-]+):(\\d{1,5})$" + } + ] + } + }, + "portsAttributes": { + "type": "object", + "patternProperties": { + "(^\\d+(-\\d+)?$)|(.+)": { + "type": "object", + "description": "A port, range of ports (ex. \"40000-55000\"), or regular expression (ex. \".+\\\\/server.js\"). For a port number or range, the attributes will apply to that port number or range of port numbers. Attributes which use a regular expression will apply to ports whose associated process command line matches the expression.", + "properties": { + "onAutoForward": { + "type": "string", + "enum": [ + "notify", + "openBrowser", + "openBrowserOnce", + "openPreview", + "silent", + "ignore" + ], + "enumDescriptions": [ + "Shows a notification when a port is automatically forwarded.", + "Opens the browser when the port is automatically forwarded. Depending on your settings, this could open an embedded browser.", + "Opens the browser when the port is automatically forwarded, but only the first time the port is forward during a session. Depending on your settings, this could open an embedded browser.", + "Opens a preview in the same window when the port is automatically forwarded.", + "Shows no notification and takes no action when this port is automatically forwarded.", + "This port will not be automatically forwarded." + ], + "description": "Defines the action that occurs when the port is discovered for automatic forwarding", + "default": "notify" + }, + "elevateIfNeeded": { + "type": "boolean", + "description": "Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port.", + "default": false + }, + "label": { + "type": "string", + "description": "Label that will be shown in the UI for this port.", + "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false + }, + "protocol": { + "type": "string", + "enum": [ + "http", + "https" + ], + "description": "The protocol to use when forwarding this port." + } + }, + "default": { + "label": "Application", + "onAutoForward": "notify" + } + } + }, + "markdownDescription": "Set default properties that are applied when a specific port number is forwarded. For example:\n\n```\n\"3000\": {\n \"label\": \"Application\"\n},\n\"40000-55000\": {\n \"onAutoForward\": \"ignore\"\n},\n\".+\\\\/server.js\": {\n \"onAutoForward\": \"openPreview\"\n}\n```", + "defaultSnippets": [ + { + "body": { + "${1:3000}": { + "label": "${2:Application}", + "onAutoForward": "notify" + } + } + } + ], + "additionalProperties": false + }, + "otherPortsAttributes": { + "type": "object", + "properties": { + "onAutoForward": { + "type": "string", + "enum": [ + "notify", + "openBrowser", + "openPreview", + "silent", + "ignore" + ], + "enumDescriptions": [ + "Shows a notification when a port is automatically forwarded.", + "Opens the browser when the port is automatically forwarded. Depending on your settings, this could open an embedded browser.", + "Opens a preview in the same window when the port is automatically forwarded.", + "Shows no notification and takes no action when this port is automatically forwarded.", + "This port will not be automatically forwarded." + ], + "description": "Defines the action that occurs when the port is discovered for automatic forwarding", + "default": "notify" + }, + "elevateIfNeeded": { + "type": "boolean", + "description": "Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port.", + "default": false + }, + "label": { + "type": "string", + "description": "Label that will be shown in the UI for this port.", + "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false + }, + "protocol": { + "type": "string", + "enum": [ + "http", + "https" + ], + "description": "The protocol to use when forwarding this port." + } + }, + "defaultSnippets": [ + { + "body": { + "onAutoForward": "ignore" + } + } + ], + "markdownDescription": "Set default properties that are applied to all ports that don't get properties from the setting `remote.portsAttributes`. For example:\n\n```\n{\n \"onAutoForward\": \"ignore\"\n}\n```", + "additionalProperties": false + }, + "updateRemoteUserUID": { + "type": "boolean", + "description": "Controls whether on Linux the container's user should be updated with the local user's UID and GID. On by default when opening from a local folder." + }, + "containerEnv": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Container environment variables." + }, + "containerUser": { + "type": "string", + "description": "The user the container will be started with. The default is the user on the Docker image." + }, + "mounts": { + "type": "array", + "description": "Mount points to set up when creating the container. See Docker's documentation for the --mount option for the supported syntax.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/Mount" + }, + { + "type": "string" + } + ] + } + }, + "init": { + "type": "boolean", + "description": "Passes the --init flag when creating the dev container." + }, + "privileged": { + "type": "boolean", + "description": "Passes the --privileged flag when creating the dev container." + }, + "capAdd": { + "type": "array", + "description": "Passes docker capabilities to include when creating the dev container.", + "examples": [ + "SYS_PTRACE" + ], + "items": { + "type": "string" + } + }, + "securityOpt": { + "type": "array", + "description": "Passes docker security options to include when creating the dev container.", + "examples": [ + "seccomp=unconfined" + ], + "items": { + "type": "string" + } + }, + "remoteEnv": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Remote environment variables to set for processes spawned in the container including lifecycle scripts and any remote editor/IDE server process." + }, + "remoteUser": { + "type": "string", + "description": "The username to use for spawning processes in the container including lifecycle scripts and any remote editor/IDE server process. The default is the same user as the container." + }, + "initializeCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run locally (i.e Your host machine, cloud VM) before anything else. This command is run before \"onCreateCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "onCreateCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when creating the container. This command is run after \"initializeCommand\" and before \"updateContentCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "updateContentCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when creating the container and rerun when the workspace content was updated while creating the container. This command is run after \"onCreateCommand\" and before \"postCreateCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postCreateCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run after creating the container. This command is run after \"updateContentCommand\" and before \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postStartCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run after starting the container. This command is run after \"postCreateCommand\" and before \"postAttachCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postAttachCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when attaching to the container. This command is run after \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "waitFor": { + "type": "string", + "enum": [ + "initializeCommand", + "onCreateCommand", + "updateContentCommand", + "postCreateCommand", + "postStartCommand" + ], + "description": "The user command to wait for before continuing execution in the background while the UI is starting up. The default is \"updateContentCommand\"." + }, + "userEnvProbe": { + "type": "string", + "enum": [ + "none", + "loginShell", + "loginInteractiveShell", + "interactiveShell" + ], + "description": "User environment probe to run. The default is \"loginInteractiveShell\"." + }, + "hostRequirements": { + "type": "object", + "description": "Host hardware requirements.", + "properties": { + "cpus": { + "type": "integer", + "minimum": 1, + "description": "Number of required CPUs." + }, + "memory": { + "type": "string", + "pattern": "^\\d+([tgmk]b)?$", + "description": "Amount of required RAM in bytes. Supports units tb, gb, mb and kb." + }, + "storage": { + "type": "string", + "pattern": "^\\d+([tgmk]b)?$", + "description": "Amount of required disk space in bytes. Supports units tb, gb, mb and kb." + }, + "gpu": { + "oneOf": [ + { + "type": [ + "boolean", + "string" + ], + "enum": [ + true, + false, + "optional" + ], + "description": "Indicates whether a GPU is required. The string \"optional\" indicates that a GPU is optional. An object value can be used to configure more detailed requirements." + }, + { + "type": "object", + "properties": { + "cores": { + "type": "integer", + "minimum": 1, + "description": "Number of required cores." + }, + "memory": { + "type": "string", + "pattern": "^\\d+([tgmk]b)?$", + "description": "Amount of required RAM in bytes. Supports units tb, gb, mb and kb." + } + }, + "description": "Indicates whether a GPU is required. The string \"optional\" indicates that a GPU is optional. An object value can be used to configure more detailed requirements.", + "additionalProperties": false + } + ] + } + }, + "unevaluatedProperties": false + }, + "customizations": { + "type": "object", + "description": "Tool-specific configuration. Each tool should use a JSON object subproperty with a unique name to group its customizations." + }, + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + } + }, + "nonComposeBase": { + "type": "object", + "properties": { + "appPort": { + "type": [ + "integer", + "string", + "array" + ], + "description": "Application ports that are exposed by the container. This can be a single port or an array of ports. Each port can be a number or a string. A number is mapped to the same port on the host. A string is passed to Docker unchanged and can be used to map ports differently, e.g. \"8000:8010\".", + "items": { + "type": [ + "integer", + "string" + ] + } + }, + "runArgs": { + "type": "array", + "description": "The arguments required when starting in the container.", + "items": { + "type": "string" + } + }, + "shutdownAction": { + "type": "string", + "enum": [ + "none", + "stopContainer" + ], + "description": "Action to take when the user disconnects from the container in their editor. The default is to stop the container." + }, + "overrideCommand": { + "type": "boolean", + "description": "Whether to overwrite the command specified in the image. The default is true." + }, + "workspaceFolder": { + "type": "string", + "description": "The path of the workspace folder inside the container." + }, + "workspaceMount": { + "type": "string", + "description": "The --mount parameter for docker run. The default is to mount the project folder at /workspaces/$project." + } + } + }, + "dockerfileContainer": { + "oneOf": [ + { + "type": "object", + "properties": { + "build": { + "type": "object", + "description": "Docker build-related options.", + "allOf": [ + { + "type": "object", + "properties": { + "dockerfile": { + "type": "string", + "description": "The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file." + }, + "context": { + "type": "string", + "description": "The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file." + } + }, + "required": [ + "dockerfile" + ] + }, + { + "$ref": "#/definitions/buildOptions" + } + ], + "unevaluatedProperties": false + } + }, + "required": [ + "build" + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "dockerFile": { + "type": "string", + "description": "The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file." + }, + "context": { + "type": "string", + "description": "The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file." + } + }, + "required": [ + "dockerFile" + ] + }, + { + "type": "object", + "properties": { + "build": { + "description": "Docker build-related options.", + "$ref": "#/definitions/buildOptions" + } + } + } + ] + } + ] + }, + "buildOptions": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Target stage in a multi-stage build." + }, + "args": { + "type": "object", + "additionalProperties": { + "type": [ + "string" + ] + }, + "description": "Build arguments." + }, + "cacheFrom": { + "type": [ + "string", + "array" + ], + "description": "The image to consider as a cache. Use an array to specify multiple images.", + "items": { + "type": "string" + } + }, + "options": { + "type": "array", + "description": "Additional arguments passed to the build command.", + "items": { + "type": "string" + } + } + } + }, + "imageContainer": { + "type": "object", + "properties": { + "image": { + "type": "string", + "description": "The docker image that will be used to create the container." + } + }, + "required": [ + "image" + ] + }, + "composeContainer": { + "type": "object", + "properties": { + "dockerComposeFile": { + "type": [ + "string", + "array" + ], + "description": "The name of the docker-compose file(s) used to start the services.", + "items": { + "type": "string" + } + }, + "service": { + "type": "string", + "description": "The service you want to work on. This is considered the primary container for your dev environment which your editor will connect to." + }, + "runServices": { + "type": "array", + "description": "An array of services that should be started and stopped.", + "items": { + "type": "string" + } + }, + "workspaceFolder": { + "type": "string", + "description": "The path of the workspace folder inside the container. This is typically the target path of a volume mount in the docker-compose.yml." + }, + "shutdownAction": { + "type": "string", + "enum": [ + "none", + "stopCompose" + ], + "description": "Action to take when the user disconnects from the primary container in their editor. The default is to stop all of the compose containers." + }, + "overrideCommand": { + "type": "boolean", + "description": "Whether to overwrite the command specified in the image. The default is false." + } + }, + "required": [ + "dockerComposeFile", + "service", + "workspaceFolder" + ] + }, + "Mount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bind", + "volume" + ], + "description": "Mount type." + }, + "source": { + "type": "string", + "description": "Mount source." + }, + "target": { + "type": "string", + "description": "Mount target." + } + }, + "required": [ + "type", + "target" + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "allOf": [ + { + "oneOf": [ + { + "allOf": [ + { + "oneOf": [ + { + "$ref": "#/definitions/dockerfileContainer" + }, + { + "$ref": "#/definitions/imageContainer" + } + ] + }, + { + "$ref": "#/definitions/nonComposeBase" + } + ] + }, + { + "$ref": "#/definitions/composeContainer" + } + ] + }, + { + "$ref": "#/definitions/devContainerCommon" + } + ] + }, + { + "type": "object", + "$ref": "#/definitions/devContainerCommon", + "additionalProperties": false + } + ], + "unevaluatedProperties": false +} diff --git a/schema/data/devContainer.codespaces.schema.json b/schema/data/devContainer.codespaces.schema.json new file mode 100644 index 0000000..3f8400a --- /dev/null +++ b/schema/data/devContainer.codespaces.schema.json @@ -0,0 +1,201 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "customizations": { + "type": "object", + "properties": { + "codespaces": { + "type": "object", + "description": "Customizations specific to GitHub Codespaces", + "properties": { + "repositories": { + "type": "object", + "description": "Configuration relative to the given repositories, following the format 'owner/repo'.\n A wildcard (*) is permitted for the repo name (eg: 'microsoft/*')", + "patternProperties": { + "^[a-zA-Z0-9-_.]+[.]*\/[a-zA-Z0-9-_*]+[.]*$": { + "type": "object", + "additionalProperties": true, + "oneOf": [ + { + "properties": { + "permissions": { + "type": "object", + "description": "Additional repository permissions.\n See https://aka.ms/ghcs/multi-repo-auth for more info.", + "additionalProperties": true, + "anyOf": [ + { + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "packages": { + "type": "string", + "enum": [ + "read" + ] + } + } + }, + { + "properties": { + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + { + "properties": { + "workflows": { + "type": "string", + "enum": [ + "write" + ] + } + } + } + ] + } + } + }, + { + "properties": { + "permissions": { + "type": "string", + "description": "Additional repository permissions.\n See https://aka.ms/ghcs/multi-repo-auth for more info.", + "enum": [ + "read-all", + "write-all" + ] + } + } + } + ] + } + } + }, + "openFiles": { + "type": "array", + "description": "The paths to the files to open when the codespace is created. Paths are relative to the workspace.", + "items": { + "type": "string" + } + }, + "disableAutomaticConfiguration": { + "type": "boolean", + "description": "Disables the setup that is automatically run in a codespace if no `postCreateCommand` is specified.", + "default": false + } + } + } + } + }, + "codespaces": { + "type": "object", + "additionalProperties": true, + "description": "Codespaces-specific configuration.", + "deprecated": true, + "deprecationMessage": "Use 'customizations/codespaces' instead" + } + } +} diff --git a/schema/data/devContainer.schema.json b/schema/data/devContainer.schema.json new file mode 100644 index 0000000..652f1bf --- /dev/null +++ b/schema/data/devContainer.schema.json @@ -0,0 +1,13 @@ +{ + "allOf": [ + { + "$ref": "./devContainer.base.schema.json" + }, + { + "$ref": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/configuration-editing/schemas/devContainer.codespaces.schema.json" + }, + { + "$ref": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/configuration-editing/schemas/devContainer.vscode.schema.json" + } + ] +} diff --git a/schema/data/devContainer.vscode.schema.json b/schema/data/devContainer.vscode.schema.json new file mode 100644 index 0000000..2c84a7e --- /dev/null +++ b/schema/data/devContainer.vscode.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "customizations": { + "type": "object", + "properties": { + "vscode": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "description": "An array of extensions that should be installed into the container. A minus '-' in front of the extension id removes it from the list of extensions to be installed.", + "items": { + "type": "string", + "pattern": "^-?([a-z0-9A-Z][a-z0-9A-Z-]*)\\.([a-z0-9A-Z][a-z0-9A-Z-]*)((@(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|@prerelease)?$", + "errorMessage": "Expected format: '${publisher}.${name}', '-${publisher}.${name}' or '${publisher}.${name}@${version}'. Example: 'ms-dotnettools.csharp'." + } + }, + "settings": { + "$ref": "vscode://schemas/settings/machine", + "description": "Machine specific settings that should be copied into the container. These are only copied when connecting to the container for the first time, rebuilding the container then triggers it again." + }, + "mcp": { + "$ref": "vscode://schemas/mcp", + "description": "Model Context Protocol server configurations" + }, + "devPort": { + "type": "integer", + "description": "The port VS Code can use to connect to its backend." + } + } + } + } + }, + "extensions": { + "type": "array", + "description": "An array of extensions that should be installed into the container. A minus '-' in front of the extension id removes it from the list of extensions to be installed.", + "items": { + "type": "string", + "pattern": "^-?([a-z0-9A-Z][a-z0-9A-Z-]*)\\.([a-z0-9A-Z][a-z0-9A-Z-]*)((@(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|@prerelease)?$", + "errorMessage": "Expected format: '${publisher}.${name}', '-${publisher}.${name}' or '${publisher}.${name}@${version}'. Example: 'ms-dotnettools.csharp'." + }, + "deprecated": true, + "deprecationMessage": "Use 'customizations/vscode/extensions' instead" + }, + "settings": { + "$ref": "vscode://schemas/settings/machine", + "description": "Machine specific settings that should be copied into the container. These are only copied when connecting to the container for the first time, rebuilding the container then triggers it again.", + "deprecated": true, + "deprecationMessage": "Use 'customizations/vscode/settings' instead" + }, + "devPort": { + "type": "integer", + "description": "The port VS Code can use to connect to its backend.", + "deprecated": true, + "deprecationMessage": "Use 'customizations/vscode/devPort' instead" + } + } +} diff --git a/schema/data/devContainerFeature.schema.json b/schema/data/devContainerFeature.schema.json new file mode 100644 index 0000000..9e0c31c --- /dev/null +++ b/schema/data/devContainerFeature.schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Development Container Feature Metadata", + "description": "Development Container Features Metadata (devcontainer-feature.json). See https://containers.dev/implementors/features/ for more information.", + "definitions": { + "Feature": { + "additionalProperties": false, + "properties": { + "capAdd": { + "description": "Passes docker capabilities to include when creating the dev container.", + "examples": [ + "SYS_PTRACE" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "containerEnv": { + "description": "Container environment variables.", + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "customizations": { + "description": "Tool-specific configuration. Each tool should use a JSON object subproperty with a unique name to group its customizations.", + "additionalProperties": true, + "type": "object" + }, + "description": { + "description": "Description of the Feature. For the best appearance in an implementing tool, refrain from including markdown or HTML in the description.", + "type": "string" + }, + "documentationURL": { + "description": "URL to documentation for the Feature.", + "type": "string" + }, + "keywords": { + "description": "List of strings relevant to a user that would search for this definition/Feature.", + "items": { + "type": "string" + }, + "type": "array" + }, + "entrypoint": { + "description": "Entrypoint script that should fire at container start up.", + "type": "string" + }, + "id": { + "description": "ID of the Feature. The id should be unique in the context of the repository/published package where the feature exists and must match the name of the directory where the devcontainer-feature.json resides.", + "type": "string" + }, + "init": { + "description": "Adds the tiny init process to the container (--init) when the Feature is used.", + "type": "boolean" + }, + "installsAfter": { + "description": "Array of ID's of Features that should execute before this one. Allows control for feature authors on soft dependencies between different Features.", + "items": { + "type": "string" + }, + "type": "array" + }, + "dependsOn": { + "description": "An object of Feature dependencies that must be satisified before this Feature is installed. Elements follow the same semantics of the features object in devcontainer.json", + "additionalProperties": true, + "type": "object" + }, + "licenseURL": { + "description": "URL to the license for the Feature.", + "type": "string" + }, + "mounts": { + "description": "Mounts a volume or bind mount into the container.", + "items": { + "$ref": "#/definitions/Mount" + }, + "type": "array" + }, + "name": { + "description": "Display name of the Feature.", + "type": "string" + }, + "options": { + "description": "Possible user-configurable options for this Feature. The selected options will be passed as environment variables when installing the Feature into the container.", + "additionalProperties": { + "$ref": "#/definitions/FeatureOption" + }, + "type": "object" + }, + "privileged": { + "description": "Sets privileged mode (--privileged) for the container.", + "type": "boolean" + }, + "securityOpt": { + "description": "Sets container security options to include when creating the container.", + "items": { + "type": "string" + }, + "type": "array" + }, + "version": { + "description": "The version of the Feature. Follows the semanatic versioning (semver) specification.", + "type": "string" + }, + "legacyIds": { + "description": "Array of old IDs used to publish this Feature. The property is useful for renaming a currently published Feature within a single namespace.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deprecated": { + "description": "Indicates that the Feature is deprecated, and will not receive any further updates/support. This property is intended to be used by the supporting tools for highlighting Feature deprecation.", + "type": "boolean" + }, + "onCreateCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when creating the container. This command is run after \"initializeCommand\" and before \"updateContentCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "updateContentCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when creating the container and rerun when the workspace content was updated while creating the container. This command is run after \"onCreateCommand\" and before \"postCreateCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postCreateCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run after creating the container. This command is run after \"updateContentCommand\" and before \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postStartCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run after starting the container. This command is run after \"postCreateCommand\" and before \"postAttachCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + }, + "postAttachCommand": { + "type": [ + "string", + "array", + "object" + ], + "description": "A command to run when attaching to the container. This command is run after \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.", + "items": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "FeatureOption": { + "anyOf": [ + { + "description": "Option value is represented with a boolean value.", + "additionalProperties": false, + "properties": { + "default": { + "description": "Default value if the user omits this option from their configuration.", + "type": "boolean" + }, + "description": { + "description": "A description of the option displayed to the user by a supporting tool.", + "type": "string" + }, + "type": { + "description": "The type of the option. Can be 'boolean' or 'string'. Options of type 'string' should use the 'enum' or 'proposals' property to provide a list of allowed values.", + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type", + "default" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "description": "Default value if the user omits this option from their configuration.", + "type": "string" + }, + "description": { + "description": "A description of the option displayed to the user by a supporting tool.", + "type": "string" + }, + "enum": { + "description": "Allowed values for this option. Unlike 'proposals', the user cannot provide a custom value not included in the 'enum' array.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "description": "The type of the option. Can be 'boolean' or 'string'. Options of type 'string' should use the 'enum' or 'proposals' property to provide a list of allowed values.", + "const": "string", + "type": "string" + } + }, + "required": [ + "type", + "enum", + "default" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "description": "Default value if the user omits this option from their configuration.", + "type": "string" + }, + "description": { + "description": "A description of the option displayed to the user by a supporting tool.", + "type": "string" + }, + "proposals": { + "description": "Suggested values for this option. Unlike 'enum', the 'proposals' attribute indicates the installation script can handle arbitrary values provided by the user.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "description": "The type of the option. Can be 'boolean' or 'string'. Options of type 'string' should use the 'enum' or 'proposals' property to provide a list of allowed values.", + "const": "string", + "type": "string" + } + }, + "required": [ + "type", + "default" + ], + "type": "object" + } + ] + }, + "Mount": { + "description": "Mounts a volume or bind mount into the container.", + "additionalProperties": false, + "properties": { + "source": { + "description": "Mount source.", + "type": "string" + }, + "target": { + "description": "Mount target.", + "type": "string" + }, + "type": { + "description": "Type of mount. Can be 'bind' or 'volume'.", + "enum": [ + "bind", + "volume" + ], + "type": "string" + } + }, + "required": [ + "type", + "target" + ], + "type": "object" + } + }, + "oneOf": [ + { + "type": "object", + "$ref": "#/definitions/Feature" + } + ] +} diff --git a/schema/errors.go b/schema/errors.go new file mode 100644 index 0000000..d319c96 --- /dev/null +++ b/schema/errors.go @@ -0,0 +1,271 @@ +package schema + +import ( + "fmt" + "sort" + "strings" + + "github.com/agext/levenshtein" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +// printer renders the library's built-in error wording for kinds decolint does not phrase itself. +var printer = message.NewPrinter(language.English) + +// leaf is one terminal validation error: a node in the error tree with no further causes. +type leaf struct { + loc []string // instance location: unescaped JSON Pointer segments + kind jsonschema.ErrorKind + schema string // absolute keyword location the error came from +} + +// diagnostics turns a validation error tree into positioned diagnostics. +// +// The tree is reduced to its relevant leaves (see [selectLeaves]), which fall into two classes: +// value errors (wrong type, bad enum, missing required property) and unknown-property errors. When +// any value error is present the unknown-property leaves are dropped: a failed combinator leaves +// every property unevaluated, so the schema reports each one as unknown, which is noise beside the +// real error. Unknown-property leaves are surfaced only when they are the sole reason validation +// failed — the case of a genuine typo or an unsupported property. +func diagnostics(root *jsonschema.ValidationError, props propertyNames, suppressExtensions bool, offsetFor func(loc []string) int) []Diagnostic { + leaves := selectLeaves(root) + + var value, unknown []leaf + for _, l := range leaves { + if isUnknownProperty(l) { + unknown = append(unknown, l) + } else if !isStructural(l.kind) { + value = append(value, l) + } + } + + var diags []Diagnostic + if len(value) > 0 { + for _, l := range value { + diags = append(diags, Diagnostic{Message: valueMessage(l), Offset: offsetFor(l.loc)}) + } + } else { + for _, l := range unknown { + diags = append(diags, unknownDiagnostics(l, props, suppressExtensions, offsetFor)...) + } + } + return dedupe(diags) +} + +// selectLeaves reduces an error tree to the leaves worth reporting. At an "oneOf"/"anyOf" node it +// keeps only the single best-matching branch instead of every branch: a devcontainer.json that is, +// say, an image container fails the Dockerfile and Compose branches too, and those failures are +// noise. Every other node — "allOf" and the structural wrappers — contributes all of its children, +// since each of their constraints genuinely applies. +func selectLeaves(e *jsonschema.ValidationError) []leaf { + if len(e.Causes) == 0 { + return []leaf{{loc: e.InstanceLocation, kind: e.ErrorKind, schema: e.SchemaURL}} + } + switch e.ErrorKind.(type) { + case *kind.OneOf, *kind.AnyOf: + var best []leaf + first := true + for _, c := range e.Causes { + branch := selectLeaves(c) + if first || betterBranch(branch, best) { + best, first = branch, false + } + } + return best + default: + var out []leaf + for _, c := range e.Causes { + out = append(out, selectLeaves(c)...) + } + return out + } +} + +// betterBranch reports whether the candidate branch is a closer match to the document than the +// incumbent. A branch matches better when it fails on fewer constraints; ties break toward the +// branch with fewer "unknown property" failures (a missing field is a nearer miss than a wrong +// shape), then toward the more deeply located failure, then lexically for determinism. +func betterBranch(candidate, incumbent []leaf) bool { + if len(candidate) != len(incumbent) { + return len(candidate) < len(incumbent) + } + if cu, iu := countUnknown(candidate), countUnknown(incumbent); cu != iu { + return cu < iu + } + if cd, id := maxDepth(candidate), maxDepth(incumbent); cd != id { + return cd > id + } + return joinMessages(candidate) < joinMessages(incumbent) +} + +func countUnknown(leaves []leaf) int { + n := 0 + for _, l := range leaves { + if isUnknownProperty(l) { + n++ + } + } + return n +} + +func maxDepth(leaves []leaf) int { + d := 0 + for _, l := range leaves { + if len(l.loc) > d { + d = len(l.loc) + } + } + return d +} + +func joinMessages(leaves []leaf) string { + parts := make([]string, len(leaves)) + for i, l := range leaves { + parts[i] = strings.Join(l.loc, "/") + ":" + l.schema + } + sort.Strings(parts) + return strings.Join(parts, "|") +} + +// isStructural reports whether a kind merely records that a combinator failed, carrying no error of +// its own. Such kinds are dropped in favor of their more specific descendants. +func isStructural(k jsonschema.ErrorKind) bool { + switch k.(type) { + case *kind.AllOf, *kind.AnyOf, *kind.OneOf, *kind.Group, *kind.Not, *kind.Reference, *kind.Schema: + return true + default: + return false + } +} + +// isUnknownProperty reports whether a leaf means "this property is not permitted here": either an +// additionalProperties violation, or a property rejected by a root "unevaluatedProperties": false. +func isUnknownProperty(l leaf) bool { + switch l.kind.(type) { + case *kind.AdditionalProperties: + return true + case *kind.FalseSchema: + return strings.HasSuffix(l.schema, "/unevaluatedProperties") || + strings.HasSuffix(l.schema, "/additionalProperties") + default: + return false + } +} + +// unknownDiagnostics renders the unknown-property leaf as one diagnostic per offending property, +// each suggesting the nearest known property name. When suppressExtensions is set (the main variant), +// root properties contributed by the VS Code and Codespaces schemas are skipped: the base schema +// cannot see them and reports them as unknown even though the main variant accepts them (see +// [propertyNames]). +func unknownDiagnostics(l leaf, props propertyNames, suppressExtensions bool, offsetFor func(loc []string) int) []Diagnostic { + var diags []Diagnostic + switch k := l.kind.(type) { + case *kind.AdditionalProperties: + for _, name := range k.Properties { + if suppressExtensions && props.extensionRoot[name] && len(l.loc) == 0 { + continue + } + loc := append(append([]string{}, l.loc...), name) + diags = append(diags, Diagnostic{Message: unknownMessage(name, props), Offset: offsetFor(loc)}) + } + case *kind.FalseSchema: + if len(l.loc) == 0 { + return nil + } + name := l.loc[len(l.loc)-1] + if suppressExtensions && props.extensionRoot[name] && len(l.loc) == 1 { + return nil + } + diags = append(diags, Diagnostic{Message: unknownMessage(name, props), Offset: offsetFor(l.loc)}) + } + return diags +} + +// unknownMessage builds an "unknown property" message, appending a "did you mean" suggestion when a +// close known property name exists. +func unknownMessage(name string, props propertyNames) string { + msg := fmt.Sprintf("unknown property %q", name) + if suggestion := nearest(name, props.candidates); suggestion != "" { + msg += fmt.Sprintf("; did you mean %q?", suggestion) + } + return msg +} + +// nearest returns the candidate property name closest to name by edit distance, or "" when none is +// within a small threshold. A candidate equal to name is ignored, so a property that is valid +// elsewhere never suggests itself. +func nearest(name string, candidates map[string]bool) string { + best := "" + bestDist := len(name)/3 + 1 // allow roughly one edit per three characters, at least one + for cand := range candidates { + if cand == name { + continue + } + if d := levenshtein.Distance(name, cand, nil); d <= bestDist { + if d < bestDist || best == "" || cand < best { + best, bestDist = cand, d + } + } + } + return best +} + +// valueMessage phrases a value error (wrong type, bad enum, missing property, …). Kinds decolint +// does not special-case fall back to the library's own wording. +func valueMessage(l leaf) string { + switch k := l.kind.(type) { + case *kind.Type: + return fmt.Sprintf("%s must be %s, but is %s", subject(l.loc), strings.Join(k.Want, " or "), k.Got) + case *kind.Enum: + return fmt.Sprintf("%s has an unsupported value", subject(l.loc)) + case *kind.Required: + if len(k.Missing) == 1 { + return fmt.Sprintf("missing required property %q", k.Missing[0]) + } + return fmt.Sprintf("missing required properties %s", strings.Join(quoteAll(k.Missing), ", ")) + default: + return fmt.Sprintf("%s: %s", subject(l.loc), k.LocalizedString(printer)) + } +} + +// subject names the value an error is about: the property or element by its pointer, or "the +// document" at the root. +func subject(loc []string) string { + if len(loc) == 0 { + return "the document" + } + return "property " + fmt.Sprintf("%q", "/"+strings.Join(loc, "/")) +} + +func quoteAll(names []string) []string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = fmt.Sprintf("%q", n) + } + return out +} + +// dedupe removes diagnostics that repeat the same message at the same offset (oneOf branches often +// produce the same error twice) and returns them ordered by position. +func dedupe(diags []Diagnostic) []Diagnostic { + seen := map[string]bool{} + var out []Diagnostic + for _, d := range diags { + key := fmt.Sprintf("%d\x00%s", d.Offset, d.Message) + if seen[key] { + continue + } + seen[key] = true + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Offset != out[j].Offset { + return out[i].Offset < out[j].Offset + } + return out[i].Message < out[j].Message + }) + return out +} diff --git a/schema/errors_test.go b/schema/errors_test.go new file mode 100644 index 0000000..da838b9 --- /dev/null +++ b/schema/errors_test.go @@ -0,0 +1,105 @@ +package schema + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/santhosh-tekuri/jsonschema/v6/kind" +) + +func TestNearest(t *testing.T) { + t.Parallel() + + candidates := map[string]bool{"forwardPorts": true, "workspaceFolder": true, "image": true} + tests := []struct { + name string + want string + }{ + {"forwardPort", "forwardPorts"}, // one edit away + {"workspaceFolde", "workspaceFolder"}, + {"image", ""}, // equals a candidate: never suggests itself + {"completelyunrelated", ""}, // too far from any candidate + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := nearest(tt.name, candidates); got != tt.want { + t.Errorf("nearest(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestValueMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + leaf leaf + want string + }{ + { + name: "type", + leaf: leaf{loc: []string{"forwardPorts"}, kind: &kind.Type{Got: "string", Want: []string{"array"}}}, + want: `property "/forwardPorts" must be array, but is string`, + }, + { + name: "enum", + leaf: leaf{loc: []string{"shutdownAction"}, kind: &kind.Enum{Got: "x", Want: []any{"none"}}}, + want: `property "/shutdownAction" has an unsupported value`, + }, + { + name: "single missing required", + leaf: leaf{loc: nil, kind: &kind.Required{Missing: []string{"image"}}}, + want: `missing required property "image"`, + }, + { + name: "multiple missing required", + leaf: leaf{loc: nil, kind: &kind.Required{Missing: []string{"dockerComposeFile", "service"}}}, + want: `missing required properties "dockerComposeFile", "service"`, + }, + { + name: "fallback uses the library wording", + leaf: leaf{loc: []string{"runArgs"}, kind: &kind.MinItems{Got: 0, Want: 1}}, + want: `property "/runArgs": minItems: got 0, want 1`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := valueMessage(tt.leaf); got != tt.want { + t.Errorf("valueMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSubject(t *testing.T) { + t.Parallel() + + if got := subject(nil); got != "the document" { + t.Errorf("subject(nil) = %q, want %q", got, "the document") + } + if got := subject([]string{"a", "0"}); got != `property "/a/0"` { + t.Errorf("subject = %q, want %q", got, `property "/a/0"`) + } +} + +func TestDedupe(t *testing.T) { + t.Parallel() + + in := []Diagnostic{ + {Message: "b", Offset: 10}, + {Message: "a", Offset: 5}, + {Message: "a", Offset: 5}, // exact duplicate, dropped + {Message: "a", Offset: 10}, + } + want := []Diagnostic{ + {Message: "a", Offset: 5}, + {Message: "a", Offset: 10}, + {Message: "b", Offset: 10}, + } + if diff := cmp.Diff(want, dedupe(in)); diff != "" { + t.Errorf("dedupe mismatch (-want +got):\n%s", diff) + } +} diff --git a/schema/props.go b/schema/props.go new file mode 100644 index 0000000..381840f --- /dev/null +++ b/schema/props.go @@ -0,0 +1,113 @@ +package schema + +import "sync" + +// propertyNames derives, from the vendored schemas, the property-name sets the diagnostics use: +// - candidates: every property name declared anywhere in the schemas, used to suggest a correction +// for a misspelled property. +// - extensionRoot: the root properties contributed only by the VS Code and Codespaces schemas. +// Because the main schema composes those as sibling allOf branches of the base schema, the base +// schema's root "unevaluatedProperties": false cannot observe them and reports them as unknown. +// Suppressing that class of false positive is what lets the main variant accept these properties. +type propertyNames struct { + candidates map[string]bool + extensionRoot map[string]bool +} + +var ( + devcontainerPropsOnce sync.Once + devcontainerProps propertyNames + devcontainerPropsErr error + featurePropsOnce sync.Once + featureProps propertyNames + featurePropsErr error +) + +// devcontainerPropertyNames returns the property sets for devcontainer.json across the base, VS Code, +// and Codespaces schemas. +func devcontainerPropertyNames() (propertyNames, error) { + devcontainerPropsOnce.Do(func() { + candidates := map[string]bool{} + for _, path := range []string{ + "data/devContainer.base.schema.json", + "data/devContainer.codespaces.schema.json", + "data/devContainer.vscode.schema.json", + } { + doc, err := readJSON(path) + if err != nil { + devcontainerPropsErr = err + return + } + collectPropertyNames(doc, candidates) + } + extensionRoot := map[string]bool{} + for _, path := range []string{ + "data/devContainer.codespaces.schema.json", + "data/devContainer.vscode.schema.json", + } { + doc, err := readJSON(path) + if err != nil { + devcontainerPropsErr = err + return + } + for name := range rootProperties(doc) { + extensionRoot[name] = true + } + } + devcontainerProps = propertyNames{candidates: candidates, extensionRoot: extensionRoot} + }) + return devcontainerProps, devcontainerPropsErr +} + +// featurePropertyNames returns the property sets for devcontainer-feature.json. The feature schema +// has no cross-resource composition, so extensionRoot is empty. +func featurePropertyNames() (propertyNames, error) { + featurePropsOnce.Do(func() { + doc, err := readJSON("data/devContainerFeature.schema.json") + if err != nil { + featurePropsErr = err + return + } + candidates := map[string]bool{} + collectPropertyNames(doc, candidates) + featureProps = propertyNames{candidates: candidates, extensionRoot: map[string]bool{}} + }) + return featureProps, featurePropsErr +} + +// rootProperties returns the keys of the top-level "properties" object of a schema document. +func rootProperties(doc any) map[string]bool { + names := map[string]bool{} + obj, ok := doc.(map[string]any) + if !ok { + return names + } + props, ok := obj["properties"].(map[string]any) + if !ok { + return names + } + for name := range props { + names[name] = true + } + return names +} + +// collectPropertyNames walks a decoded schema and records every key that appears under a +// "properties" object, at any depth, into out. +func collectPropertyNames(node any, out map[string]bool) { + switch v := node.(type) { + case map[string]any: + if props, ok := v["properties"].(map[string]any); ok { + for name := range props { + out[name] = true + } + } + for _, child := range v { + collectPropertyNames(child, out) + } + case []any: + for _, child := range v { + collectPropertyNames(child, out) + } + } +} diff --git a/schema/revision.go b/schema/revision.go new file mode 100644 index 0000000..aa3a2d1 --- /dev/null +++ b/schema/revision.go @@ -0,0 +1,50 @@ +package schema + +import ( + "encoding/json/v2" + "fmt" + "sync" +) + +// revisions is the decoded data/REVISIONS.json: the upstream commits the vendored schemas were taken +// from. +type revisions struct { + Spec string `json:"spec"` + VSCode string `json:"vscode"` +} + +var ( + revisionOnce sync.Once + revisionStr string +) + +// Revision reports the upstream commits the embedded schemas were vendored from, as +// "spec@ vscode@", for display by "decolint -version". A malformed or missing revisions +// file yields "unknown" rather than an error, since it must not stop the program from reporting its +// version. +func Revision() string { + revisionOnce.Do(func() { + revisionStr = "unknown" + b, err := data.ReadFile("data/REVISIONS.json") + if err != nil { + return + } + var rev revisions + if err := json.Unmarshal(b, &rev); err != nil { + return + } + if rev.Spec == "" || rev.VSCode == "" { + return + } + revisionStr = fmt.Sprintf("spec@%s vscode@%s", short(rev.Spec), short(rev.VSCode)) + }) + return revisionStr +} + +// short truncates a git commit hash to its 7-character short form. +func short(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..14843b0 --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,122 @@ +// Package schema validates devcontainer configuration files against the official Dev Container JSON +// Schemas. It complements the rule engine: the schemas cover the shape of a file — property names, +// value types, enums, and unknown properties — while rules cover security, reproducibility, and +// cross-property semantics. +// +// The schemas are vendored (see the data directory) and embedded, so validation needs no network +// access. [Revision] reports the upstream commits they were taken from. +package schema + +import ( + "bytes" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// Variant selects which devcontainer.json schema to validate against. +type Variant int + +const ( + // VariantOff disables schema validation. + VariantOff Variant = iota + // VariantBase validates against the spec base schema only, rejecting VS Code- and + // Codespaces-specific properties. + VariantBase + // VariantMain validates against the main schema, which additionally allows the VS Code and + // Codespaces extensions to devcontainer.json. + VariantMain +) + +// ParseVariant parses a variant name ("off", "base", or "main"). Any other value is an error. +func ParseVariant(s string) (Variant, error) { + switch s { + case "off": + return VariantOff, nil + case "base": + return VariantBase, nil + case "main": + return VariantMain, nil + default: + return VariantOff, fmt.Errorf("unknown variant %q (want one of: base, main, off)", s) + } +} + +// String returns the variant's name, as accepted by [ParseVariant]. +func (v Variant) String() string { + switch v { + case VariantOff: + return "off" + case VariantBase: + return "base" + case VariantMain: + return "main" + default: + return "unknown" + } +} + +// Kind identifies which file schema applies. Templates have no official schema and are not passed to +// [Validate]. +type Kind int + +const ( + // KindDevcontainer selects the devcontainer.json schema (base or main per the variant). + KindDevcontainer Kind = iota + // KindFeature selects the devcontainer-feature.json schema, independent of the variant. + KindFeature +) + +// Diagnostic is one schema violation, positioned by a byte offset into the source that produced std. +type Diagnostic struct { + Message string + Offset int +} + +// Validate validates std, the standardized (comment- and trailing-comma-free) JSON bytes of a file +// of the given kind, against the selected schema, and returns its violations. It returns nil when +// v is [VariantOff] or the document is valid. +// +// offsetFor maps a schema instance location (a slice of unescaped JSON Pointer segments) to a byte +// offset into std, so each diagnostic can be positioned; see [github.com/bare-devcontainer/decolint/linter.Document.OffsetAt]. +func Validate(v Variant, kind Kind, std []byte, offsetFor func(loc []string) int) ([]Diagnostic, error) { + if v == VariantOff { + return nil, nil + } + + entryURL, props, err := setup(v, kind) + if err != nil { + return nil, err + } + sch, err := compile(entryURL) + if err != nil { + return nil, err + } + + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(std)) + if err != nil { + return nil, fmt.Errorf("decode document: %w", err) + } + verr := sch.Validate(inst) + if verr == nil { + return nil, nil + } + root, ok := verr.(*jsonschema.ValidationError) + if !ok { + return nil, fmt.Errorf("validate document: %w", verr) + } + return diagnostics(root, props, v == VariantMain, offsetFor), nil +} + +// setup resolves the schema entry URL and property sets for a variant and kind. +func setup(v Variant, kind Kind) (string, propertyNames, error) { + if kind == KindFeature { + props, err := featurePropertyNames() + return urlFeature, props, err + } + props, err := devcontainerPropertyNames() + if v == VariantBase { + return urlBase, props, err + } + return urlMain, props, err +} diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..39ca375 --- /dev/null +++ b/schema/schema_test.go @@ -0,0 +1,236 @@ +package schema + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseVariant(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want Variant + wantErr bool + }{ + {"off", VariantOff, false}, + {"base", VariantBase, false}, + {"main", VariantMain, false}, + {"", VariantOff, true}, + {"Main", VariantOff, true}, + {"bogus", VariantOff, true}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + got, err := ParseVariant(tt.in) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseVariant(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil { + if got != tt.want { + t.Errorf("ParseVariant(%q) = %v, want %v", tt.in, got, tt.want) + } + if got.String() != tt.in { + t.Errorf("Variant.String() = %q, want %q", got.String(), tt.in) + } + } + }) + } +} + +// messages validates src and returns just the diagnostic messages, positioning every finding at +// offset 0 since these tests assert wording rather than location (see TestValidate_Position). +func messages(t *testing.T, v Variant, kind Kind, src string) []string { + t.Helper() + diags, err := Validate(v, kind, []byte(src), func([]string) int { return 0 }) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if len(diags) == 0 { + return nil + } + msgs := make([]string, len(diags)) + for i, d := range diags { + msgs[i] = d.Message + } + return msgs +} + +func TestValidate_Devcontainer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + variant Variant + src string + want []string + }{ + { + name: "valid image container", + variant: VariantMain, + src: `{"image": "ubuntu@sha256:abc", "forwardPorts": [3000]}`, + want: nil, + }, + { + name: "valid compose container", + variant: VariantMain, + src: `{"dockerComposeFile": "compose.yaml", "service": "app", "workspaceFolder": "/w"}`, + want: nil, + }, + { + name: "misspelled property suggests the correct name", + variant: VariantMain, + src: `{"image": "ubuntu", "forwardPort": [3000]}`, + want: []string{`unknown property "forwardPort"; did you mean "forwardPorts"?`}, + }, + { + name: "wrong value type", + variant: VariantMain, + src: `{"image": "ubuntu", "forwardPorts": "3000"}`, + want: []string{`property "/forwardPorts" must be array, but is string`}, + }, + { + name: "unsupported enum value", + variant: VariantMain, + src: `{"dockerComposeFile": "c.yaml", "service": "app", "workspaceFolder": "/w", "shutdownAction": "bogus"}`, + want: []string{`property "/shutdownAction" has an unsupported value`}, + }, + { + name: "compose container missing a required property", + variant: VariantMain, + src: `{"dockerComposeFile": "c.yaml", "service": "app"}`, + want: []string{`missing required property "workspaceFolder"`}, + }, + { + name: "VS Code extension property allowed by main", + variant: VariantMain, + src: `{"image": "ubuntu", "customizations": {"vscode": {"extensions": ["golang.go"]}}}`, + want: nil, + }, + { + name: "deprecated top-level VS Code property allowed by main", + variant: VariantMain, + src: `{"image": "ubuntu", "extensions": ["golang.go"]}`, + want: nil, + }, + { + name: "deprecated top-level VS Code property rejected by base", + variant: VariantBase, + src: `{"image": "ubuntu", "extensions": ["golang.go"]}`, + want: []string{`unknown property "extensions"`}, + }, + { + name: "variable placeholders in string values are accepted", + variant: VariantMain, + src: `{"image": "ubuntu", "containerEnv": {"P": "${localEnv:PATH}"}, "workspaceFolder": "${localWorkspaceFolder}"}`, + want: nil, + }, + { + name: "off disables validation", + variant: VariantOff, + src: `{"forwardPort": [3000]}`, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := messages(t, tt.variant, KindDevcontainer, tt.src) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("messages mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestValidate_Feature(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want []string + }{ + { + name: "valid feature", + src: `{"id": "my-feature", "version": "1.0.0", "name": "My Feature"}`, + want: nil, + }, + { + name: "unknown property", + src: `{"id": "my-feature", "version": "1.0.0", "instalsAfter": []}`, + want: []string{`unknown property "instalsAfter"; did you mean "installsAfter"?`}, + }, + { + name: "wrong value type", + src: `{"id": "my-feature", "version": 1}`, + want: []string{`property "/version" must be string, but is number`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // The feature schema is variant-independent; base and main must agree. + for _, v := range []Variant{VariantBase, VariantMain} { + got := messages(t, v, KindFeature, tt.src) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("variant %s messages mismatch (-want +got):\n%s", v, diff) + } + } + }) + } +} + +func TestValidate_Position(t *testing.T) { + t.Parallel() + + // offsetFor records the instance location it is asked to resolve, so the test can assert that a + // finding is anchored at the offending property rather than the document root. + src := `{"image": "ubuntu", "forwardPort": [3000]}` + var gotLoc []string + diags, err := Validate(VariantMain, KindDevcontainer, []byte(src), func(loc []string) int { + gotLoc = loc + return 7 + }) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if len(diags) != 1 { + t.Fatalf("got %d diagnostics, want 1", len(diags)) + } + if diff := cmp.Diff([]string{"forwardPort"}, gotLoc); diff != "" { + t.Errorf("instance location mismatch (-want +got):\n%s", diff) + } + if diags[0].Offset != 7 { + t.Errorf("Offset = %d, want 7 (from offsetFor)", diags[0].Offset) + } +} + +func TestRevision(t *testing.T) { + t.Parallel() + + rev := Revision() + if rev == "unknown" { + t.Fatal("Revision() = unknown; expected the embedded REVISIONS.json to be read") + } + for _, want := range []string{"spec@", "vscode@"} { + if !contains(rev, want) { + t.Errorf("Revision() = %q, want it to contain %q", rev, want) + } + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +}