diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1f5ef00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# position fixtures depend on their exact line endings (\n, \r\n, lone \r); keeps +# autocrlf on Windows from rewriting them +src/position/testdata/*.jsonc -text diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4df073e..d649a12 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,157 +16,73 @@ concurrency: cancel-in-progress: true jobs: - # All six release targets must compile. Cross-compilation on ubuntu runners is - # far cheaper than native arm runners and asserts exactly what we need here: - # that the code builds everywhere we ship. - build: - name: build ${{ matrix.goos }}/${{ matrix.goarch }} - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - include: - - {goos: linux, goarch: amd64} - - {goos: linux, goarch: arm64} - - {goos: darwin, goarch: amd64} - - {goos: darwin, goarch: arm64} - - {goos: windows, goarch: amd64} - - {goos: windows, goarch: arm64} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Build - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: go build ./... - - # Tests run natively. The race detector is restricted to linux/amd64: it needs - # cgo and a C toolchain, and running it on every leg buys no extra signal while - # costing minutes. + # Coverage is enabled by default via bunfig.toml (text + lcov reporters), so a plain + # `bun test` already produces both — no extra flag or third-party service needed. test: name: test ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 10 - # Mapped here because the `secrets` context is not available in a - # step-level `if`, but `env` is. - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + timeout-minutes: 5 strategy: fail-fast: false matrix: - include: - - {os: ubuntu-latest, race: true} - - {os: macos-latest, race: false} - - {os: windows-latest, race: false} + os: [ubuntu-latest, macos-latest, windows-latest] steps: + # schemas/ is a symlink into the devcontainer-spec submodule — without this, + # both tsc's text-import resolution and the schema tests' readdirSync see an + # empty/missing directory. - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 with: - go-version-file: go.mod - cache: true + submodules: recursive - - name: Vet - run: go vet ./... - - - name: Test - if: ${{ !matrix.race }} - run: go test ./... + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.2" - - name: Test with race detector and coverage - if: ${{ matrix.race }} - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + - run: bun install --frozen-lockfile - # Coverage reporting that needs no secret and no third-party service: - # the number lands in the job summary, the profile in an artifact. - - name: Coverage summary - if: ${{ matrix.race }} - run: | - go tool cover -func=coverage.out | tail -1 - { - echo '### Coverage' - echo - echo '```' - go tool cover -func=coverage.out | tail -25 - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + - run: bun test - - name: Upload coverage profile - if: ${{ matrix.race }} + # One copy is enough; the lcov content doesn't vary by OS. + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 with: - name: coverage-profile - path: coverage.out - if-no-files-found: error + name: coverage + path: coverage/lcov.info - # Publishes trend data only when a token is configured. Gated on the - # secret rather than left to fail invisibly: a tokenless upload is - # rejected by Codecov while still reporting the step as successful. - - name: Publish to Codecov - if: ${{ matrix.race && env.CODECOV_TOKEN != '' }} - uses: codecov/codecov-action@v5 - with: - files: coverage.out - token: ${{ env.CODECOV_TOKEN }} - fail_ci_if_error: false - - # Go fuzzes one target per invocation, and its corpus of "interesting" inputs is - # a machine-local cache by design — not something to commit. The seed corpus - # (f.Add, plus any crasher checked in) already runs as an ordinary test in the - # `test` job; this job is the part that explores inputs nobody has seen yet. - # - # Time-boxed rather than exhaustive: the point is steady pressure on every PR. - # On a failure the toolchain writes the reproducer under testdata/fuzz/, so it - # is uploaded — committing that file turns the crash into a permanent test. - fuzz: - name: fuzz + # tsc is the correctness gate; prettier only checks formatting. Neither needs more + # than one OS. + check: + name: typecheck & format runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 steps: + # Same reason as the test job: tsc resolves schemas/*.schema.json through the + # devcontainer-spec submodule symlink. - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 with: - go-version-file: go.mod - cache: true + submodules: recursive - - name: Fuzz - run: make fuzz FUZZTIME=60s - - - name: Upload crash reproducers - if: failure() - uses: actions/upload-artifact@v4 + - uses: oven-sh/setup-bun@v2 with: - name: fuzz-crashers - path: "**/testdata/fuzz/**" - if-no-files-found: ignore + bun-version: "1.4.2" + + - run: bun install --frozen-lockfile + - run: bunx tsc --noEmit + - run: bun run format:check - # Lint results are platform-independent, so this runs once rather than three - # times. It also exercises a Makefile target, which keeps CI and the local - # `make check` entry point from drifting apart. - lint: - name: lint + # Proves the compile step itself doesn't break on every PR, without paying for the + # full 8-target release matrix (that's a separate, on-tag job). + build-smoke: + name: build smoke runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: oven-sh/setup-bun@v2 with: - go-version-file: go.mod - cache: true + bun-version: "1.4.2" - - name: Check formatting - run: make fmt-check - - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.13.2 + - run: bun install --frozen-lockfile + - run: bun run build diff --git a/.github/workflows/schema-drift.yaml b/.github/workflows/schema-drift.yaml new file mode 100644 index 0000000..19a1130 --- /dev/null +++ b/.github/workflows/schema-drift.yaml @@ -0,0 +1,75 @@ +name: Schema drift + +# schemas/ is a symlink into the pinned devcontainer-spec submodule, not an +# independent copy — so there is no local file to diff against upstream. What can +# actually go stale is the pin itself: this job compares the submodule's checked-out +# commit against devcontainers/spec's remote main and opens a PR bumping the pin +# (and regenerating the Ajv validators against the new schema content) on drift. +# Never fails the build — a stale pin is worth a PR, not a red CI run. + +on: + schedule: + - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check-drift: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Compare pinned commit against upstream main + id: drift + run: | + pinned="$(git -C devcontainer-spec rev-parse HEAD)" + upstream="$(git ls-remote https://github.com/devcontainers/spec.git refs/heads/main | cut -f1)" + echo "pinned=$pinned" >> "$GITHUB_OUTPUT" + echo "upstream=$upstream" >> "$GITHUB_OUTPUT" + if [ "$pinned" = "$upstream" ]; then + echo "drifted=false" >> "$GITHUB_OUTPUT" + else + echo "drifted=true" >> "$GITHUB_OUTPUT" + fi + + - uses: oven-sh/setup-bun@v2 + if: steps.drift.outputs.drifted == 'true' + + - name: Bump the pin and regenerate validators + if: steps.drift.outputs.drifted == 'true' + run: | + git -C devcontainer-spec checkout "${{ steps.drift.outputs.upstream }}" + + retrieved="$(date -u +%Y-%m-%d)" + tmp="$(mktemp)" + jq --arg commit "${{ steps.drift.outputs.upstream }}" \ + --arg retrieved "$retrieved" \ + 'map_values(.commit = $commit | .retrieved = $retrieved | .url |= sub("/spec/[^/]+/schemas/"; "/spec/" + $commit + "/schemas/"))' \ + src/schema/provenance.json > "$tmp" + mv "$tmp" src/schema/provenance.json + + bun install + bun run scripts/build-validators.ts + + - name: Open a PR + if: steps.drift.outputs.drifted == 'true' + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "chore: bump devcontainer-spec to ${{ steps.drift.outputs.upstream }}" + title: "chore: bump devcontainer-spec pin to ${{ steps.drift.outputs.upstream }}" + body: | + `devcontainers/spec`'s `main` moved from `${{ steps.drift.outputs.pinned }}` + to `${{ steps.drift.outputs.upstream }}`. This bumps the submodule pin, + refreshes `src/schema/provenance.json`, and regenerates the Ajv validators + against the new schema content. + + Opened automatically by the schema-drift workflow — review the schema diff + before merging. + branch: chore/schema-drift + delete-branch: true diff --git a/.gitignore b/.gitignore index 20684f3..a14702c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,34 @@ -# Build output -/dist/ -/bin/ -/dcx -/dcx.exe +# dependencies (bun install) +node_modules -# Test and coverage artefacts -*.test -*.out -coverage.* +# output +out +dist +*.tgz -# Editor and OS noise -.DS_Store -.idea/ -*.swp +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local -# Extension build output -extensions/vscode/node_modules/ -extensions/vscode/out/ -extensions/vscode/*.vsix +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1410cb9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "devcontainer-spec"] + path = devcontainer-spec + url = https://github.com/devcontainers/spec.git diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 4ad4636..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: "2" - -linters: - enable: - - errcheck - - govet - - ineffassign - - staticcheck - - unused - - revive - - misspell - - gocritic - - bodyclose - - errorlint - exclusions: - presets: - - std-error-handling - rules: - # pkg/doc.go is a documentation-only package whose name intentionally - # differs from its directory. - - path: pkg/doc\.go - linters: - - revive - # Table-driven tests carry long literal blocks. - - path: _test\.go - linters: - - gocritic - -formatters: - enable: - - gofmt - - goimports - settings: - goimports: - local-prefixes: - - github.com/lonhutt/dcx diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6853af8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# .gitignore is honoured automatically; these are tracked but not ours to format. + +# Upstream spec submodule and schemas vendored from it +devcontainer-spec +schemas + +# Fixtures may be deliberately malformed +testdata + +bun.lock + +# Standalone Ajv output, generated by scripts/build-validators.ts — committed, not ours to format +src/schema/generated diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..48b70a0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c83e263 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5f8094a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Bun: Debug Active Test File", + "type": "bun", + "request": "launch", + "program": "${file}", + "runtimeArgs": ["test"], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5be1482 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "prettier.requireConfig": true +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8baee3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7578977..0000000 --- a/Makefile +++ /dev/null @@ -1,85 +0,0 @@ -BINARY := dcx -PKG := github.com/lonhutt/dcx - -# Windows has no Unix `date`, and GNU make dispatches $(shell) through cmd.exe -# there, so guard the shell-dependent defaults. CI passes these in explicitly. -ifeq ($(OS),Windows_NT) - EXT := .exe - DATE ?= unknown -else - EXT := - DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -endif - -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) - -# Race is the local default. CI clears it on the legs where it is not wanted: -# it needs cgo and a C toolchain, and adds nothing outside linux/amd64. -TESTFLAGS ?= -race - -LDFLAGS := -s -w \ - -X main.version=$(VERSION) \ - -X main.commit=$(COMMIT) \ - -X main.date=$(DATE) - -.DEFAULT_GOAL := check - -.PHONY: build -build: ## Build the CLI into ./bin - go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)$(EXT) ./cmd/$(BINARY) - -.PHONY: test -test: ## Run tests (TESTFLAGS=-race by default) - go test $(TESTFLAGS) ./... - -.PHONY: cover -cover: ## Run tests and write a coverage profile - go test $(TESTFLAGS) -coverprofile=coverage.out -covermode=atomic ./... - go tool cover -func=coverage.out | tail -1 - -.PHONY: vet -vet: ## Run go vet - go vet ./... - -.PHONY: lint -lint: ## Run golangci-lint - golangci-lint run - -.PHONY: fmt -fmt: ## Format the tree - gofmt -w . - -.PHONY: fmt-check -fmt-check: ## Fail if anything is unformatted - @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "unformatted:"; echo "$$out"; exit 1; fi - -# Go fuzzes one target per invocation, so discover them rather than naming one: -# a hardcoded target silently passes once the package it names moves or is not -# written yet. Exits non-zero if it finds nothing, for the same reason. -FUZZTIME ?= 30s - -.PHONY: fuzz -fuzz: ## Bounded fuzz run over every Fuzz target (FUZZTIME=30s) - @set -e; \ - found=0; \ - for pkg in $$(go list ./...); do \ - for fn in $$(go test -list='Fuzz.*' $$pkg 2>/dev/null | grep '^Fuzz' || true); do \ - found=1; \ - echo "==> $$pkg $$fn ($(FUZZTIME))"; \ - go test -run='^$$' -fuzz="^$$fn$$" -fuzztime=$(FUZZTIME) $$pkg; \ - done; \ - done; \ - if [ $$found -eq 0 ]; then echo "no fuzz targets found"; exit 1; fi - -.PHONY: check -check: fmt-check vet lint test ## Everything CI runs - -.PHONY: clean -clean: - rm -rf bin dist coverage.out - -.PHONY: help -help: ## List targets - @grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | sort | \ - awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 201ccfb..d0b7204 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,15 @@ # dcx -[![CI](https://github.com/lonhutt/dcx/actions/workflows/ci.yaml/badge.svg)](https://github.com/lonhutt/dcx/actions/workflows/ci.yaml) -[![Go Reference](https://pkg.go.dev/badge/github.com/lonhutt/dcx.svg)](https://pkg.go.dev/github.com/lonhutt/dcx) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +To install dependencies: -Static analysis for dev container configuration, built against the -[Development Container Specification](https://containers.dev/implementors/spec/). - -> **Status: skeleton.** The repository layout and tooling are in place; no rules are -> implemented yet. See [`docs/DESIGN.md`](docs/DESIGN.md) for the full design. - -## Why - -The reference `devcontainers/cli` reads and merges `devcontainer.json` but performs no -schema validation. Editors that do validate produce unusable errors, because the -official schema's top level is a nest of `oneOf` branches — a missing `image` key -yields `must match exactly one schema in oneOf` rather than something you can act on. - -`dcx` discriminates the configuration scenario itself, then reports: - -``` -.devcontainer/devcontainer.json:14:3: error: 'image' and 'dockerComposeFile' cannot - both be set — a Compose configuration takes its image from the Compose file - [scenario/conflicting-source] -``` - -## Install - -```sh -go install github.com/lonhutt/dcx/cmd/dcx@latest -``` - -Release archives, Homebrew, Scoop, Linux packages and a container image are planned — -see Design §12. - -## Usage - -``` -dcx check [path...] Analyse devcontainer.json -dcx serve --stdio Run the language server -dcx explain Describe a rule -dcx feature Analyse devcontainer-feature.json +```bash +bun install ``` -One binary, subcommands rather than separate tools — so the CLI, the language server, -and the editor extension all ship the same artefact. +To run: -## Planned surface - -- **71 rules across 15 categories** — syntax, schema conformance, container-source - discrimination, cross-field semantics, referenced-path existence, deprecations, - Features, ports, mounts, lifecycle commands, security, VS Code customizations, - reproducibility, style. -- **Offline by default.** Network-dependent rules are opt-in via `--online` and - degrade to a warning when a registry is unreachable. -- **`text`, `compact`, `json`, `sarif`, and `github` output**, with exit code 1 for - findings and 2 for tool errors so CI can tell them apart. -- **Editor support** via a VS Code extension and a language server sharing this core. - -## Configuration - -`.dcx.yaml` (also `.yml`, `.json`) in your repository, discovered by walking upward. -Rules can be suppressed inline, using the comments JSONC already allows: - -```jsonc -{ - // dcx-disable-next-line security/docker-socket-mount - "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"] -} -``` - -## Contributing - -Issues and pull requests: - -## Development - -```sh -make check # fmt-check, vet, lint, test — everything CI runs -make build # build ./bin/dcx -make help # list all targets +```bash +bun run index.ts ``` -Requires Go 1.27+ and [golangci-lint](https://golangci-lint.run) v2. - -## Layout - -Everything under `pkg/` is public, semver-stable API; `cmd/` is not part of that -contract. See [`pkg/doc.go`](pkg/doc.go) and Design §4.3. - -| Path | Purpose | -| --- | --- | -| `cmd/dcx/` | Single binary: check, serve, explain, feature | -| `pkg/jsonc/` | JSONC lexer and concrete syntax tree | -| `pkg/position/` | Byte offsets, UTF-8 columns, UTF-16 LSP positions | -| `pkg/vfs/` | Filesystem abstraction, including editor overlays | -| `pkg/diagnostic/` | Diagnostic, severity, range, fix | -| `pkg/model/` | Typed semantic model and scenario discrimination | -| `pkg/schema/` | Embedded JSON Schema and error translation | -| `pkg/rules/` | Rule interface, registry, engine | -| `pkg/report/` | Output formatters | -| `pkg/lint/` | Facade shared by the CLI, LSP, and extension | -| `schemas/` | Vendored upstream JSON schemas | -| `extensions/vscode/` | VS Code extension | - -## License - -MIT — see [LICENSE](LICENSE). +This project was created using `bun init` in bun v1.4.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ef3860d --- /dev/null +++ b/bun.lock @@ -0,0 +1,88 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "dcx", + "dependencies": { + "ajv": "^8.20.0", + "jsonc-parser": "^3.3.1", + "yaml": "^2.9.0", + }, + "devDependencies": { + "@types/bun": "latest", + "prettier": "3.9.6", + }, + "peerDependencies": { + "typescript": "^7", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9ac3903 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[test] +coverage = true # Enables coverage by default on every 'bun test' run +coverageReporter = ["text", "lcov"] # Generates terminal summary AND lcov file diff --git a/cmd/dcx/main.go b/cmd/dcx/main.go deleted file mode 100644 index c268943..0000000 --- a/cmd/dcx/main.go +++ /dev/null @@ -1,76 +0,0 @@ -// Command dcx is a static analyser and language server for dev container -// configuration. -// -// It ships as a single binary with subcommands rather than separate tools, so -// the CLI, the language server, and the editor extension all share one -// distribution artefact: -// -// dcx check [path...] analyse devcontainer.json -// dcx serve --stdio run the language server -// dcx explain describe a rule -// dcx feature analyse devcontainer-feature.json -package main - -import ( - "fmt" - "os" -) - -// Build metadata, overridden at release time via -ldflags. -var ( - version = "dev" - commit = "none" - date = "unknown" -) - -// Exit codes are a compatibility contract: 0 clean, 1 lint findings at or above -// the failure threshold, 2 a tool error. CI relies on telling 1 from 2. -const ( - exitClean = 0 - exitFindings = 1 - exitToolErr = 2 -) - -const usage = `dcx — static analysis for dev container configuration - -Usage: - dcx [flags] [path...] - -Commands: - check Analyse devcontainer.json (default when a path is given) - serve Run the language server over stdio - explain Print the long-form description of a rule - feature Analyse devcontainer-feature.json - version Print version information - -Run "dcx --help" for command-specific flags. -` - -func main() { - os.Exit(run(os.Args[1:])) -} - -// run holds all the logic so main stays a single call. Nothing below cmd/ is -// permitted to call os.Exit; keeping that boundary here from the start is what -// lets the language server reuse the same code paths. -func run(args []string) int { - if len(args) == 0 { - fmt.Fprint(os.Stderr, usage) - return exitToolErr - } - - switch args[0] { - case "version", "--version", "-v": - fmt.Printf("dcx %s (%s, built %s)\n", version, commit, date) - return exitClean - case "help", "--help", "-h": - fmt.Print(usage) - return exitClean - case "check", "serve", "explain", "feature": - fmt.Fprintf(os.Stderr, "dcx: %s is not implemented yet (skeleton)\n", args[0]) - return exitToolErr - default: - fmt.Fprintf(os.Stderr, "dcx: unknown command %q\n\n%s", args[0], usage) - return exitToolErr - } -} diff --git a/devcontainer-spec b/devcontainer-spec new file mode 160000 index 0000000..c95ffee --- /dev/null +++ b/devcontainer-spec @@ -0,0 +1 @@ +Subproject commit c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421 diff --git a/docs/DESIGN.md b/docs/DESIGN.md deleted file mode 100644 index 3337d24..0000000 --- a/docs/DESIGN.md +++ /dev/null @@ -1,1113 +0,0 @@ -# dcx — Design Document - -**Status:** Draft for review -**Date:** 2026-09-04 -**Language:** Go (see [Language Decision](#2-language-decision)) - ---- - -## 1. Overview - -`dcx` is a standalone, dependency-free static analyser for `devcontainer.json`, -built against the -[Development Container Specification](https://containers.dev/implementors/spec/). - -It is the first component of a three-part family: - -| Component | Deliverable | Status | -| --- | --- | --- | -| **Core library** (`pkg/…`) | Reusable Go packages: parse → model → analyze → diagnose | This doc | -| **CLI** (`cmd/dcx`) | `dcx check` — standalone binary for terminals, CI, pre-commit | This doc | -| **LSP server** (`cmd/dcx`) | `dcx serve` — the same binary, over stdio | Designed for, built later | -| **VSCode extension** (`extensions/vscode`) | Thin TypeScript client | This doc | - -### 1.1 Goals - -- **G1** — Catch every class of `devcontainer.json` defect that can be detected - statically, with precise source spans and human-readable messages. -- **G2** — Ship as a single static binary with no runtime dependency. -- **G3** — Be architecturally ready for an LSP from day one: no global state, no - direct filesystem access from rules, cancellable analysis, byte-accurate - positions, and machine-applicable fixes. -- **G4** — Be usable non-interactively: stable exit codes, JSON and SARIF output, - GitHub annotations, pre-commit hook. -- **G5** — Be configurable: per-rule severity, inline suppressions, project config file. -- **G6** — Work fully offline by default; network-dependent rules are opt-in. - -### 1.2 Non-goals (v1) - -- Building, starting, or otherwise executing dev containers. This is a *static* - analyser; it never invokes Docker. -- Linting `Dockerfile` contents (defer to `hadolint`) or full `docker-compose.yml` - validation (defer to `docker compose config`). We only cross-reference *the parts - a `devcontainer.json` points at* — e.g. "does the named compose service exist". -- Auto-generating or scaffolding dev container configs. -- Being a drop-in replacement for `devcontainers/cli`. We complement it: the - reference CLI parses and merges config but performs **no** schema validation. - -### 1.3 Why this project exists - -Research into the current ecosystem found: - -- The reference `devcontainers/cli` reads and merges `devcontainer.json` but does - **not** validate it against the published JSON Schema. -- The only third-party tooling found is a GitHub Action that checks for the - *presence* of specific user-chosen keys — not a general linter. -- Editors that do schema-validate produce unusable errors, because the official - schema's top level is a nest of `oneOf` branches. A missing `image` key yields - `"must match exactly one schema in oneOf"` rather than - `"no container source: expected one of image, build.dockerfile, or dockerComposeFile"`. - -The gap is real, and the highest-value work is **not** running a schema validator — -it is discriminating the configuration scenario ourselves and emitting diagnostics a -human can act on. - ---- - -## 2. Language Decision - -**Chosen: Go.** - -### 2.1 Rationale - -| Criterion | Go | TypeScript | Python | -| --- | --- | --- | --- | -| CLI distribution | Single static binary, no runtime | Needs Node | Needs Python/uv | -| Cold start | ~5 ms | ~150–300 ms | ~200–400 ms | -| JSON Schema 2019-09 + `unevaluatedProperties` | `santhosh-tekuri/jsonschema/v6` | Ajv 2019 | `jsonschema` 4.x | -| JSONC parser with positions | Hand-rolled (~700 LOC) | `jsonc-parser` (ideal) | Hand-rolled | -| LSP framework | `tliron/glsp`, `go.lsp.dev` | `vscode-languageserver-node` (reference impl) | `pygls` | -| Cross-compilation for VSIX targets | Trivial | N/A | Painful | - -The decisive argument: **the VSCode extension is a thin client in every scenario.** -Once an LSP exists, the extension is ~200 lines that spawn a server process and wire -up `LanguageClient`. TypeScript's "one language everywhere" advantage is therefore -much smaller than it first appears, while distribution size and startup latency are -permanent, daily-felt properties of a linter — and Go wins both outright. - -### 2.2 Accepted costs - -- **A hand-written JSONC parser.** Bounded (~700 LOC), one-time, and it lets us build - exactly the CST a linter wants: comments retained and attachable, trailing commas - recorded rather than discarded, duplicate keys preserved, and error recovery that - keeps analysing after a syntax fault. -- **A bilingual repository.** Go core plus a small TypeScript extension. Managed by - keeping the boundary narrow: the extension only ever talks JSON-over-stdio or LSP. -- **Platform-specific VSIX packaging.** Six build targets. Handled once in CI via - GoReleaser plus `vsce package --target`. - ---- - -## 3. Specification Model - -Facts extracted from the spec that drive the design. - -### 3.1 Discovery order - -Per the spec, configuration is searched in this precedence order: - -1. `.devcontainer/devcontainer.json` -2. `.devcontainer.json` -3. `.devcontainer//devcontainer.json` (single level of nesting) - -### 3.2 Format - -`devcontainer.json` is **JSONC**. The official schema explicitly sets -`allowComments: true` and `allowTrailingCommas: true`. Any parser that rejects -comments is wrong for this format. - -### 3.3 Schema shape - -The upstream `devContainer.base.schema.json` (~24 KB) is **JSON Schema draft 2019-09** -and uses `unevaluatedProperties`. Its top-level structure is: - -``` -oneOf: - ├─ allOf: - │ ├─ oneOf: - │ │ ├─ allOf: [ oneOf: [dockerfileContainer, imageContainer], nonComposeBase ] - │ │ └─ composeContainer - │ └─ devContainerCommon - └─ devContainerCommon (additionalProperties: false) -``` - -Definitions and their properties: - -- **`devContainerCommon`** — `$schema`, `name`, `features`, - `overrideFeatureInstallOrder`, `secrets`, `forwardPorts`, `portsAttributes`, - `otherPortsAttributes`, `updateRemoteUserUID`, `containerEnv`, `containerUser`, - `mounts`, `init`, `privileged`, `capAdd`, `securityOpt`, `remoteEnv`, `remoteUser`, - the six lifecycle commands, `waitFor`, `userEnvProbe`, `hostRequirements`, - `customizations`, `additionalProperties`. -- **`nonComposeBase`** — `appPort`, `runArgs`, `shutdownAction`, `overrideCommand`, - `workspaceFolder`, `workspaceMount`. -- **`imageContainer`** — requires `image`. -- **`dockerfileContainer`** — `oneOf`: modern `build.dockerfile` (+ `context`, - `target`, `args`, `cacheFrom`, `options`) **or** legacy top-level - `dockerFile` + `context`. -- **`composeContainer`** — requires `dockerComposeFile`, `service`, - `workspaceFolder`; plus `runServices`, and its own `shutdownAction` - (`none` | `stopCompose`). -- **`Mount`** — requires `type` (`bind` | `volume`) and `target`; optional `source`. - -Constrained enums worth checking explicitly: - -- `waitFor` — `initializeCommand`, `onCreateCommand`, `updateContentCommand`, - `postCreateCommand`, `postStartCommand` -- `userEnvProbe` — `none`, `loginShell`, `loginInteractiveShell`, `interactiveShell` -- `shutdownAction` — `none`, `stopContainer` (non-compose) / `none`, `stopCompose` (compose) -- `portsAttributes.*.onAutoForward` — `notify`, `openBrowser`, `openBrowserOnce`, - `openPreview`, `silent`, `ignore` -- `portsAttributes.*.protocol` — `http`, `https` - -### 3.4 Variable substitution - -Supported forms: `${localEnv:NAME}`, `${localEnv:NAME:default}`, -`${containerEnv:NAME}`, `${containerEnv:NAME:default}`, `${localWorkspaceFolder}`, -`${containerWorkspaceFolder}`, `${localWorkspaceFolderBasename}`, -`${containerWorkspaceFolderBasename}`, `${devcontainerId}`. - -Note `${containerEnv:…}` is only meaningful in `remoteEnv` — a lintable constraint. - -### 3.5 Features - -Three reference forms: OCI registry (`ghcr.io/owner/repo/feature:version`), direct -HTTPS tarball, and local relative path (`./feature`). Feature metadata lives in -`devcontainer-feature.json` with `id`, `version`, `name`, `options`, `dependsOn`, -`installsAfter`, `deprecated`, and lifecycle hooks. Options are `boolean` or -`string`, the latter optionally constrained by `enum` or suggested by `proposals`. - ---- - -## 4. Architecture - -### 4.1 Layer diagram - -``` - ┌────────────────────────────────────────┐ - CLI ──────────────┤ │ - LSP server ───────┤ pkg/lint (facade) │ - Extension ────────┤ Analyze(ctx, Document, Options) │ - └────────────────────┬───────────────────┘ - │ - ┌──────────────┬──────────────┬────────┴──────┬──────────────┬─────────────┐ - │ discovery │ jsonc │ model │ rules │ report │ - │ locate the │ lex→parse→ │ CST → typed │ engine + │ text/json/ │ - │ config file │ CST (+pos) │ semantic AST │ registry │ sarif/gh │ - └──────┬───────┴──────┬───────┴───────┬───────┴──────┬───────┴─────────────┘ - │ │ │ │ - ┌──────┴──────┐ ┌─────┴─────┐ ┌──────┴──────┐ ┌─────┴──────┐ ┌───────────┐ - │ vfs │ │ position │ │ schema │ │ features │ │ diagnostic│ - │ overlay FS │ │ LineIndex │ │ embedded │ │ OCI + cache│ │ Range/Fix │ - │ (unsaved │ │ UTF-8↔16 │ │ 2019-09 │ │ (network) │ │ Severity │ - │ buffers) │ │ │ │ validator │ │ │ │ │ - └─────────────┘ └───────────┘ └─────────────┘ └────────────┘ └───────────┘ -``` - -### 4.2 The five LSP-readiness invariants - -These are the constraints that make a future language server a straightforward -addition rather than a rewrite. **Every one of them is cheap now and expensive later.** - -1. **No `os.Exit`, no `panic`, no writing to stdout below `cmd/`.** The core returns - values. Only `main` decides process fate. -2. **All filesystem access goes through `vfs.FS`.** An editor holds unsaved buffers - that do not exist on disk; the LSP supplies an overlay FS whose contents come from - `textDocument/didChange`. A rule that calls `os.ReadFile` is unusable in an editor. -3. **Every diagnostic carries a byte-offset `Range`, never a line/column string.** - Rendering to a terminal caret or to an LSP UTF-16 position is a display concern, - handled by `pkg/position`. -4. **`Analyze` takes a `context.Context` and honours cancellation.** Editors - re-analyse on keystroke and abandon in-flight runs constantly. -5. **`Diagnostic` has an optional `Fix []TextEdit` from day one.** The CLI uses it for - `--fix`; the LSP serves it as `textDocument/codeAction`. Retrofitting fixes onto a - rule set built without them means touching every rule. - -### 4.3 Package layout - -``` -dcx/ -├── go.mod -├── cmd/ -│ └── dcx/ # single binary: check, serve, explain, feature -├── pkg/ -│ ├── lint/ # facade: Analyze(), Document, Options -│ ├── vfs/ # FS interface, OS impl, overlay impl -│ ├── position/ # Offset, Range, LineIndex, UTF-16 conversion -│ ├── diagnostic/ # Diagnostic, Severity, Fix, TextEdit -│ ├── jsonc/ # lexer, parser, CST nodes, error recovery -│ ├── discovery/ # config file location per spec §3.1 -│ ├── schema/ # go:embed'd upstream schemas + validator -│ ├── model/ # typed semantic model over the CST -│ ├── features/ # feature ref parsing, OCI resolution, cache -│ ├── registry/ # extension-registry adapters (Open VSX, gallery, static) -│ ├── lintconfig/ # .dcx.yaml loading + merge -│ ├── suppress/ # inline comment directive parsing -│ ├── rules/ -│ │ ├── engine.go # registry, ordering, execution -│ │ ├── rule.go # Rule interface -│ │ └── / # one package per rule category -│ └── report/ # text, json, sarif, github formatters -├── schemas/ # vendored upstream JSON schemas -├── testdata/ # fixture corpus + golden files -└── extensions/vscode/ # TypeScript extension -``` - -`pkg/` is the public API surface. It is documented and semver-stable so that an LSP -living in a *separate* repository remains possible — but the default plan keeps the -server in-tree as `cmd/dcx` to avoid cross-repo version skew. - ---- - -## 5. Component Specifications - -### 5.1 `pkg/jsonc` — the parser - -A hand-written lexer and recursive-descent parser producing a **concrete** syntax -tree. Concrete, not abstract: a linter must be able to point at the comma nobody -should have typed. - -**Node kinds:** `Object`, `Array`, `Property`, `String`, `Number`, `Boolean`, `Null`, -`Comment`, `Error`. - -Every node carries `Offset` and `Length` (byte-based). Objects retain their -`Property` list *in source order, including duplicates* — silently dropping a -duplicate key would hide one of the highest-value diagnostics. - -**Requirements:** - -- Line (`//`) and block (`/* */`) comments preserved as nodes, attachable to the - following property for suppression directives. -- Trailing commas parsed successfully but recorded on the node. -- **Error recovery.** On a malformed value, emit an `Error` node, resynchronise at the - next `,` or `}`, and continue. A file with one syntax error must still produce - semantic diagnostics for the rest of the document — this is what makes the editor - experience tolerable while typing. -- Native Go fuzzing over the parser; it must never panic on arbitrary bytes. - -### 5.2 `pkg/position` — coordinates - -Internally everything is a byte offset. `LineIndex` is built once per document and -converts: - -- byte offset ↔ (line, UTF-8 column) — for terminal output -- byte offset ↔ (line, UTF-16 code unit) — for LSP `Position` - -The UTF-16 conversion is required by the LSP spec and is a classic source of -off-by-N bugs with non-ASCII content. Building it in from the start costs nothing. - -### 5.3 `pkg/vfs` — filesystem abstraction - -```go -type FS interface { - ReadFile(path string) ([]byte, error) - Stat(path string) (fs.FileInfo, error) - ReadDir(path string) ([]fs.DirEntry, error) -} -``` - -Two implementations: `OSFS` (the CLI) and `OverlayFS` (the LSP — in-memory documents -layered over `OSFS`). Rules receive an `FS` and never touch `os` directly. - -### 5.4 `pkg/model` — semantic model - -Lowers the CST into a typed structure, and — critically — **discriminates the -scenario** before schema validation runs: - -```go -type Scenario int -const ( - ScenarioUnknown Scenario = iota // no container source found - ScenarioImage // has `image` - ScenarioDockerfile // has `build.dockerfile` or legacy `dockerFile` - ScenarioCompose // has `dockerComposeFile` - ScenarioAmbiguous // more than one of the above - ScenarioMetadataOnly // valid: common properties only -) -``` - -Knowing the scenario is what converts `"must match exactly one schema in oneOf"` into -`"'image' and 'dockerComposeFile' cannot both be set: a Compose configuration takes -its image from the Compose file"`. Every field on the model retains a back-pointer to -its CST node so any rule can produce an exact span. - -### 5.5 `pkg/schema` — schema validation - -The upstream schemas are vendored into `schemas/` and embedded with `go:embed` — the -linter must work offline and must not vary its behaviour with network conditions. A -CI job checks the vendored copy against upstream weekly and opens a PR on drift. - -Validation uses `santhosh-tekuri/jsonschema/v6` (draft 2019-09 + `unevaluatedProperties`). - -**Error translation is a first-class concern.** Raw validator output is routed -through a translation layer that: - -1. Uses the already-known `Scenario` to select the *relevant* `oneOf` branch and - discard errors from the branches that were never applicable. -2. Maps the JSON Pointer in each error back to a CST node for an exact span. -3. Rewrites the message into prose, adding the enum's valid values, a spelling - suggestion for unknown properties (Levenshtein over the known key set), and a - documentation link. - -### 5.6 `pkg/rules` — the rule engine - -```go -type Rule interface { - ID() string // e.g. "security/docker-socket-mount" - Description() string - DefaultSeverity() diagnostic.Severity - Category() Category - RequiresNetwork() bool - Check(ctx context.Context, p *Pass) -} - -type Pass struct { - Doc *jsonc.Document // CST + source text - Model *model.DevContainer - FS vfs.FS - Dir string // directory containing devcontainer.json - Features features.Resolver // nil when offline - Report func(diagnostic.Diagnostic) -} -``` - -Rules register themselves in an `init()` into a package-level registry. The engine: - -1. Filters by config (severity `off`) and by `RequiresNetwork()` when offline. -2. Runs rules concurrently — they are pure functions over an immutable `Pass`. -3. Collects diagnostics, applies inline suppressions, sorts by position. -4. Checks `ctx.Done()` between rules for LSP cancellation. - -### 5.7 `pkg/features` — feature resolution - -Offline, we can only check reference *syntax* and pinning. With `--online`, we fetch -each feature's `devcontainer-feature.json` from its OCI artifact to validate option -names and values against the declared `options` schema, and to surface `deprecated`. - -Cached under `$XDG_CACHE_HOME/dcx/features/` keyed by resolved digest, -with a configurable TTL. Network failures **degrade to a warning, never an error** — -a linter that fails closed on a flaky registry is a linter people disable. - -### 5.8 `pkg/registry` — extension sources and policy - -Verifying `customizations.vscode.extensions` requires knowing where extensions come -from — and **there is no single answer.** Four facts drive the design: - -1. **The Microsoft Marketplace cannot be the default.** Its Terms of Use state that - Marketplace offerings may only be installed and used with Visual Studio products - and services. That restriction is precisely why VSCodium ships pointed at Open VSX - instead. A third-party OSS linter cannot enable Marketplace queries by default on - a user's behalf. -2. **Open VSX is not a mirror.** Microsoft's proprietary extensions are simply absent - from it — a live check confirms `ms-python.vscode-pylance` returns HTTP 404 there, - while `golang.go` and `ms-azuretools.vscode-docker` resolve fine. A config that - works perfectly in VS Code silently degrades for a teammate on VSCodium, Cursor, - Windsurf, or Gitpod. -3. **Private galleries are now first-class.** VS Code's Private Marketplace - (announced 2025-11-18, GitHub Enterprise customers) deploys as a stateless Docker - container and is pointed at via `extensions.gallery.serviceUrl`, with - `extensions.gallery.authProvider` selecting the account that grants access. Older - and OSS builds use `product.json`'s `extensionsGallery.serviceUrl`. Self-hosting is - no longer an edge case. -4. **There is already a standard org policy format.** Since VS Code 1.96, - `extensions.allowed` controls which extensions may be installed, deployable via - `settings.json` or group policy. It supports publisher wildcards, per-extension - allow/deny, pinned versions, platform-qualified versions, and `"stable"`. - -Fact 2 **reframes the rule**: "does this extension ID exist" is low value — a typo -surfaces the moment the container is built. "Is this extension available to everyone -who will open this repo" is high value, because that failure is silent and only hits -the teammate on the other editor. - -Fact 4 is the bigger win, and it is why this is not just a config knob. We do **not** -invent an allowlist syntax — we consume `extensions.allowed` verbatim. An enterprise -that has already written that policy gets a working check with no new authoring, and -the check is **fully offline**. - -**Adapter interface:** - -```go -type Source interface { - ID() string - RequiresNetwork() bool - Lookup(ctx context.Context, publisher, name string) (*Extension, error) -} - -type Extension struct { - Version string - Deprecated bool - Downloadable bool // false ⇒ unpublished or removed - AllowedVersions []string // from policy sources; nil ⇒ unconstrained - TargetPlatforms []string -} -``` - -Three implementations: - -| Kind | Mechanism | Network | Auth | -| --- | --- | --- | --- | -| `openvsx` | `GET {base}/api/{namespace}/{name}`. Serves `open-vsx.org` and self-hosted instances identically. | yes | none | -| `vscode-gallery` | `POST {serviceUrl}/extensionquery` using VS Code's gallery protocol. Covers the Microsoft Marketplace, the Private Marketplace container, and any gallery implementing it. | yes | optional token | -| `policy` | Parses VS Code's own `extensions.allowed` object, from a file path or inline in our config. | **no** | none | - -### 5.8.1 Why `policy` is the recommended enterprise path - -The Private Marketplace authenticates through `extensions.gallery.authProvider` — -a GitHub Enterprise or Entra ID sign-in flow, not a static token. **The linter does -not implement OAuth**, and should not: a CI job holding an interactive enterprise -identity is a bad idea regardless of effort. - -For organisations, the `policy` source is both more tractable and more valuable. It -needs no credentials, no network, and no VPN; it answers the question that actually -bites — *will this extension install for our developers at all* — and it reads a file -the org has already written for a different purpose. Gallery queries remain available -for anyone who wants them, with a token supplied out-of-band. - -### 5.8.2 `extensions.allowed` cannot come from the repository - -There is no in-repo location VS Code honours for this policy, and that is deliberate. -`extensions.allowed` is **application-scoped**. VS Code maintains a list of settings -unsupported in workspace settings: the first time a workspace defines one, the editor -warns, and thereafter always ignores the value. So neither candidate location works: - -| Candidate | Outcome | -| --- | --- | -| `.vscode/settings.json` | Workspace scope. Warned once, then permanently ignored. | -| `customizations.vscode.settings` | Written to remote/machine settings, which is likewise not application scope. | - -The reason is a security property, not an oversight: **if a repository could set the -extension allowlist, any repository could allowlist arbitrary extensions for whoever -opened it.** Application scope exists precisely to prevent that, so no repo-provided -location can ever be authoritative here. Org policy arrives through local user -settings or group policy — outside the repository entirely. - -Two consequences: - -1. **Auto-discovery is dropped.** The `policy` source is always explicitly configured - in `.dcx.yaml` — inline, or a path to a policy file the org - distributes by its own means. It is *our* input data, not a mirror of something VS - Code reads from the repo. -2. **This is itself a lintable mistake**, and exactly the silent failure this project - exists to catch. Hence `vscode/ineffective-application-setting`: it flags any - application-scoped setting placed in `customizations.vscode.settings`, where it - will be quietly discarded. The rule covers the whole application-scoped set, not - just `extensions.allowed`. - -### 5.8.3 Configuration is layered, and split by ownership - -The user's point stands: this must be per-project. But *which* part is per-project -matters, because two different concerns are in play. - -- **Source definitions** (id, kind, url, credentials) may be declared in the project - config *and* extended by a user-level config at - `$XDG_CONFIG_HOME/dcx/config.yaml`. A developer on VSCodium can add - Open VSX to their own checks without editing a shared file. -- **Policy** (which sources are `required`, and the `satisfy` mode) is - **project-owned only**. It is a team decision about what this repo must support, - and a user-level file must not be able to weaken it. - -**Credentials are never literals.** A token is given as an env var name or a -credential-helper command, never a value. `.dcx.yaml` is a committed -file, and we ship a `security/hardcoded-secret` rule — inviting a PAT into our own -config would be indefensible. The loader rejects a literal-looking token outright. - -**Lookups are case-insensitive.** Open VSX's canonical record for `golang.go` is -namespace `golang`, name `Go`. A rule reporting "not found" on a case difference -would be a pure false positive. - -**Unreachable sources degrade to a warning, never an error** — a self-hosted gallery -is often only reachable on a VPN, and CI must not fail because of it. - ---- - -## 6. Rule Catalog - -Rule IDs are `category/kebab-name`. IDs are stable API: once shipped, a rule is never -renamed and never changes meaning. Removal requires a major version. - -Severities: `error`, `warning`, `info`, `off`. -Rules marked 🌐 require `--online`. - -### 6.1 `syntax/` — parse-level - -| ID | Default | Description | -| --- | --- | --- | -| `syntax/parse-error` | error | Malformed JSONC | -| `syntax/duplicate-key` | error | Key appears twice; later value silently wins | -| `syntax/trailing-comma` | off | Legal per spec, but some third-party parsers reject it | - -### 6.2 `schema/` — schema conformance - -| ID | Default | Description | -| --- | --- | --- | -| `schema/unknown-property` | warning | Property not in the spec; includes a spelling suggestion | -| `schema/type-mismatch` | error | Wrong JSON type for a known property | -| `schema/invalid-enum-value` | error | Value outside the allowed set; lists valid values | -| `schema/missing-required` | error | A required property for the detected scenario is absent | - -### 6.3 `scenario/` — container source discrimination - -| ID | Default | Description | -| --- | --- | --- | -| `scenario/no-container-source` | error | None of `image`, `build.dockerfile`, `dockerComposeFile` present | -| `scenario/conflicting-source` | error | More than one container source declared | -| `scenario/compose-missing-service` | error | `dockerComposeFile` without `service` | -| `scenario/compose-missing-workspace-folder` | error | Compose requires an explicit `workspaceFolder` | -| `scenario/compose-service-not-found` | error | `service` names a service absent from the Compose file | -| `scenario/non-compose-property` | warning | `runArgs`/`appPort`/`workspaceMount` are ignored under Compose | -| `scenario/shutdown-action-mismatch` | error | `stopCompose` without Compose, or `stopContainer` with it | - -### 6.4 `semantic/` — cross-field consistency - -| ID | Default | Description | -| --- | --- | --- | -| `semantic/workspace-mount-without-folder` | error | `workspaceMount` requires `workspaceFolder` | -| `semantic/wait-for-unreachable` | warning | `waitFor` names a lifecycle command that is not defined | -| `semantic/invalid-substitution` | error | Unknown `${…}` variable name | -| `semantic/substitution-scope` | warning | `${containerEnv:…}` used outside `remoteEnv` | -| `semantic/local-env-no-default` | info | `${localEnv:X}` with no default silently becomes empty | -| `semantic/remote-user-not-container-user` | info | `remoteUser` differs from `containerUser`; often intended, sometimes not | -| `semantic/update-remote-user-uid-noop` | info | Set on a config where it cannot apply | -| `semantic/build-arg-not-declared` | warning | A `build.args` key has no matching `ARG` in the referenced Dockerfile, so the value is silently discarded | - -### 6.5 `fs/` — referenced-path existence - -| ID | Default | Description | -| --- | --- | --- | -| `fs/dockerfile-not-found` | error | `build.dockerfile` does not resolve | -| `fs/context-not-found` | error | `build.context` does not resolve | -| `fs/compose-file-not-found` | error | A `dockerComposeFile` entry does not resolve | -| `fs/local-feature-not-found` | error | A `./`-relative feature path does not resolve | -| `fs/path-escapes-workspace` | warning | A referenced path traverses above the project root | - -### 6.6 `deprecation/` - -| ID | Default | Description | -| --- | --- | --- | -| `deprecation/top-level-dockerfile` | warning | Legacy `dockerFile`/`context` at root; use `build.*` — **fixable** | -| `deprecation/app-port` | warning | `appPort` superseded by `forwardPorts` | -| `deprecation/root-extensions` | warning | Root `extensions` moved to `customizations.vscode.extensions` — **fixable** | -| `deprecation/root-settings` | warning | Root `settings` moved to `customizations.vscode.settings` — **fixable** | - -### 6.7 `feature/` - -| ID | Default | Description | -| --- | --- | --- | -| `feature/invalid-reference` | error | Reference matches none of the three legal forms | -| `feature/unpinned-version` | warning | No tag, or `:latest` — breaks reproducibility | -| `feature/duplicate` | error | Same feature declared twice | -| `feature/override-order-unknown` | warning | `overrideFeatureInstallOrder` lists a feature not in `features` | -| `feature/unknown-option` 🌐 | error | Option not declared by the feature | -| `feature/invalid-option-value` 🌐 | error | Value outside the option's `enum` | -| `feature/deprecated` 🌐 | warning | Feature is marked `deprecated` upstream | -| `feature/missing-dependency` 🌐 | warning | A `dependsOn` requirement is unsatisfied | - -### 6.8 `port/` - -| ID | Default | Description | -| --- | --- | --- | -| `port/out-of-range` | error | Not in 1–65535 | -| `port/duplicate-forward` | warning | Port listed twice in `forwardPorts` | -| `port/invalid-attribute-key` | error | `portsAttributes` key is not a port, `host:port`, or range | -| `port/attributes-orphan` | info | `portsAttributes` entry for a port that is never forwarded | -| `port/privileged-without-elevate` | info | Port < 1024 without `elevateIfNeeded` | - -### 6.9 `mount/` - -| ID | Default | Description | -| --- | --- | --- | -| `mount/invalid-string-syntax` | error | String-form mount is not valid `key=value,…` | -| `mount/missing-target` | error | Mount has no `target` | -| `mount/duplicate-target` | error | Two mounts target the same path | -| `mount/absolute-host-path` | warning | Bind source is a machine-specific absolute path | - -### 6.10 `lifecycle/` - -| ID | Default | Description | -| --- | --- | --- | -| `lifecycle/shell-syntax-in-array-form` | warning | Array form bypasses the shell; `&&`, `|`, `>` will be literal arguments | -| `lifecycle/parallel-non-string-value` | error | Object (parallel) form requires string or array values | -| `lifecycle/initialize-runs-on-host` | info | `initializeCommand` executes on the host, not in the container | -| `lifecycle/empty-command` | warning | Empty command string | - -### 6.11 `security/` - -| ID | Default | Description | -| --- | --- | --- | -| `security/privileged` | warning | `privileged: true` grants near-host access | -| `security/docker-socket-mount` | warning | Mounting `/var/run/docker.sock` is effectively host root | -| `security/cap-add-sys-admin` | warning | `SYS_ADMIN` is close to full privilege | -| `security/seccomp-unconfined` | warning | `seccomp=unconfined` disables syscall filtering | -| `security/privileged-run-args` | warning | `--privileged`/`--cap-add` smuggled through `runArgs` | -| `security/hardcoded-secret` | error | Credential-shaped literal in `containerEnv`, `remoteEnv`, or `build.args` | - -### 6.12 `vscode/` — editor customizations - -| ID | Default | Description | -| --- | --- | --- | -| `vscode/invalid-extension-id` | error | Not a `publisher.name` identifier | -| `vscode/extension-not-allowed` | error | Denied by an `extensions.allowed` policy source — it will not install for anyone in the org. Offline | -| `vscode/extension-version-not-allowed` | warning | Policy pins permitted versions (or platform-qualified versions) that the requested extension does not satisfy. Offline | -| `vscode/extension-not-found` 🌐 | error | Absent from every source marked `required` | -| `vscode/extension-not-portable` 🌐 | warning | Present in some required sources but not all — the VS Code / Open VSX gap | -| `vscode/extension-deprecated` 🌐 | warning | Source reports `deprecated`, or `downloadable: false` (unpublished or removed) | -| `vscode/ineffective-application-setting` | warning | `customizations.vscode.settings` contains an application-scoped setting (`extensions.allowed`, `extensions.gallery.*`, …). VS Code discards these — the config has no effect | - -### 6.13 `repro/` — reproducibility - -| ID | Default | Description | -| --- | --- | --- | -| `repro/unpinned-image` | warning | `image` has no tag, or uses `:latest` | -| `repro/image-no-digest` | off | Opt-in: require `@sha256:` digest pinning | -| `repro/mutable-build-arg` | info | `build.args` value interpolates a host env var | - -### 6.14 `style/` - -| ID | Default | Description | -| --- | --- | --- | -| `style/missing-name` | info | No `name`; tools will display a generated label | -| `style/missing-schema` | off | No `$schema`; adding it enables editor completion — **fixable** | - -### 6.15 `meta/` — the linter's own hygiene - -| ID | Default | Description | -| --- | --- | --- | -| `meta/unused-suppression` | warning | A `dcx-disable-*` directive suppressed nothing | - -**Total: 71 rules across 15 categories.** - -> Correction: earlier drafts of this document stated 52 and then 58. Both were -> arithmetic slips in the running total; the per-category tables were correct. The -> figure above is a recount: syntax 3, schema 4, scenario 7, semantic 8, fs 5, -> deprecation 4, feature 8, port 5, mount 4, lifecycle 4, security 6, vscode 7, -> repro 3, style 2, meta 1. - ---- - -## 7. CLI Design - -### 7.1 Invocation - -``` -dcx check [flags] [path...] -``` - -`path` may be a `devcontainer.json` file, or a directory. With no path, the current -directory is used. - -### 7.2 Target resolution - -Given a directory, resolve in spec precedence order: - -1. `/.devcontainer/devcontainer.json` -2. `/.devcontainer.json` -3. `/.devcontainer/*/devcontainer.json` — **all** matches are linted - -If none is found, exit 2 with a message naming the paths searched. `--recursive` -walks the tree for every dev container config beneath the target, honouring -`.gitignore`. - -### 7.3 Flags - -| Flag | Description | -| --- | --- | -| `--format ` | `text` (default), `json`, `sarif`, `github`, `compact` | -| `--config ` | Explicit config file; disables discovery | -| `--no-config` | Ignore any project config | -| `--online` | Enable network-dependent rules | -| `--offline` | Force offline (default) | -| `--rule ` | Run only these rules (repeatable) | -| `--disable ` | Disable these rules (repeatable) | -| `--severity =` | Override one rule's severity | -| `--max-severity ` | Cap severity, e.g. treat everything as at most `warning` | -| `--error-on-warning` | Exit non-zero for warnings too | -| `--fix` | Apply machine-applicable fixes in place | -| `--fix-dry-run` | Print the unified diff `--fix` would apply | -| `--no-color` / `--color=` | Colour control (`auto`, `always`, `never`) | -| `--quiet` | Only print diagnostics, no summary | -| `--explain ` | Print the long-form explanation for a rule and exit | -| `--list-rules` | Print the rule catalog (respects `--format json`) | -| `--version` | Version, commit, build date | - -### 7.4 Exit codes - -| Code | Meaning | -| --- | --- | -| 0 | No diagnostics at or above the failure threshold | -| 1 | Lint findings at/above the threshold (default: any `error`) | -| 2 | Tool error: bad usage, unreadable file, no config found | - -Separating 1 from 2 is what lets CI distinguish "your config is wrong" from "the -linter broke". - -### 7.5 Text output - -``` -.devcontainer/devcontainer.json:14:3: error: 'image' and 'dockerComposeFile' cannot - both be set — a Compose configuration takes its image from the Compose file - [scenario/conflicting-source] - - 12 │ "name": "api", - 13 │ "dockerComposeFile": "docker-compose.yml", - 14 │ "image": "mcr.microsoft.com/devcontainers/go:1", - │ ^^^^^^^ - 15 │ "service": "app", - - help: remove "image", or replace "dockerComposeFile" and "service" with a - single-container configuration - docs: https://containers.dev/implementors/json_reference/#compose-specific - -✖ 1 error, 2 warnings in 1 file -``` - -`compact` format is one line per diagnostic (`file:line:col: severity: message [id]`) -for editor `errorformat` integration and grep. - -### 7.6 SARIF - -SARIF 2.1.0 with `rules[]` populated from the registry, so GitHub code scanning shows -descriptions and help URIs. This makes the linter a first-class citizen in the -GitHub Security tab with no extra work from the user. - ---- - -## 8. Configuration - -### 8.1 File - -`.dcx.yaml` (also `.yml`, `.json`) — **these three and nothing else; no -TOML, no bespoke format** — discovered by walking upward from the linted file to the -repository root. - -```yaml -version: 1 - -# Fail the run on anything at or above this severity. -fail-on: error - -# Enable rules that need network access. -online: false - -rules: - security/privileged: error # promote - style/missing-name: off # disable - repro/image-no-digest: warning # enable an off-by-default rule - syntax/trailing-comma: error - -# Turn whole categories off at once. -categories: - style: off - -# Paths excluded from linting (gitignore syntax). -exclude: - - "examples/**" - - "testdata/**" - -features: - # Allow these otherwise-unpinned features. - allow-unpinned: - - "ghcr.io/devcontainers/features/common-utils" - cache-ttl: 24h - -# ── Extension sources ──────────────────────────────────────────────── -# Definitions may be extended by user-level config; `required` may not. -extension-sources: - - id: openvsx - kind: openvsx - url: https://open-vsx.org - required: true - - # VS Code's own `extensions.allowed` format, verbatim. Offline, no auth. - - id: corp-policy - kind: policy - path: .vscode/extensions-policy.json - required: true - - # Or inline, using the same syntax: - # - id: corp-policy - # kind: policy - # required: true - # allowed: - # "microsoft": true - # "ms-azuretools.vscode-containers": false - # "dbaeumer.vscode-eslint": ["3.0.0"] - # "rust-lang.rust-analyzer": ["5.0.0@win32-x64", "5.0.0@darwin-x64"] - # "redhat": "stable" - - # Private Marketplace or Microsoft Marketplace. Opt-in; you are responsible - # for compliance with the gallery's terms of use. - - id: corp-gallery - kind: vscode-gallery - url: https://marketplace.corp.example/_apis/public/gallery - token-env: CORP_GALLERY_TOKEN # env var NAME — never a literal - required: false - -extensions: - # all — must satisfy every source marked `required` (the portability check) - # any — must satisfy at least one - satisfy: all -``` - -Precedence, lowest to highest: rule defaults → user config → project config → -environment → CLI flags. The one exception is `required` and `satisfy` under -`extension-sources`, which are project-owned: a user-level config may add source -definitions but may not weaken the project's policy. - -### 8.2 Inline suppression - -Because the format is JSONC, comment directives are natural — and this is a genuine -differentiator over schema-only validation. - -```jsonc -{ - // dcx-disable-next-line security/docker-socket-mount - "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"], - - "privileged": true, // dcx-disable-line security/privileged -- CI needs this -} -``` - -- `dcx-disable-next-line ` -- `dcx-disable-line ` -- `dcx-disable-file ` (must be in the leading comment block) -- Everything after ` -- ` is a reason, preserved in JSON/SARIF output. -- With no IDs, all rules are suppressed for that scope. -- A `meta/unused-suppression` rule (default `warning`) flags directives that - suppressed nothing — otherwise suppressions rot silently. - ---- - -## 9. LSP Integration Plan - -The server is a **thin adapter**, not a second implementation. It is built once the -CLI rule set is stable (M8), and its existence is what §4.2's invariants pay for. - -### 9.1 Server surface - -| Capability | Backed by | -| --- | --- | -| `textDocument/publishDiagnostics` | `lint.Analyze` on open/change (debounced ~200 ms) | -| `textDocument/codeAction` | `Diagnostic.Fix` — already produced by rules | -| `textDocument/hover` | Property descriptions from the embedded schema | -| `textDocument/completion` | Property names, enum values, feature IDs 🌐 | -| `textDocument/documentLink` | `build.dockerfile`, `dockerComposeFile`, local features | -| `textDocument/definition` | Jump from `service` to its Compose definition | -| `workspace/executeCommand` | "Fix all auto-fixable problems" | - -### 9.2 Mechanics - -- Transport: stdio (`--stdio`), matching every editor's expectation. -- Sync: incremental (`TextDocumentSyncKind.Incremental`); `OverlayFS` holds buffers. -- Cancellation: each `didChange` cancels the previous analysis `context`. -- Positions: `pkg/position` converts byte offsets to UTF-16, per LSP spec. -- The server shares the CLI's config discovery, so a project's - `.dcx.yaml` governs the editor identically. - -### 9.3 One binary, not two - -The server is a **subcommand of the same binary**, not a separate executable: -`dcx serve --stdio` alongside `dcx check`. Three reasons: - -1. **The extension bundles one artefact instead of two.** DCL-47 ships a - platform-specific VSIX for six targets; two binaries would double both the - payload and the packaging matrix. -2. **The server and the rule set change together.** A split would make every rule - addition a two-artefact release with a version-skew window in between. -3. **Users install one thing.** `brew install dcx` gives you the CLI, the language - server, and everything the extension needs. - -The `pkg/` API stays documented and semver-stable regardless, so extracting the -server later remains possible if it ever grows its own release rhythm. - -## 10. VSCode Extension - -`extensions/vscode`, TypeScript, deliberately minimal. - -### 10.1 Two-phase plan - -**Phase 1 (M7) — CLI-driven.** No LSP required. The extension spawns -`dcx check --format json` on open and on save, parses the output, and -populates a `DiagnosticCollection`. Roughly 250 lines. This ships real value long -before the server exists, and it validates the JSON output contract. - -**Phase 2 (M8) — LSP-driven.** Replace the runner with `vscode-languageclient` -spawning `dcx serve --stdio`. Diagnostics arrive over the wire; hover, -completion, code actions, and document links come along for free. The Phase 1 code -path is deleted, not maintained in parallel. - -### 10.2 Binary resolution - -In order: `dcx.path` setting → bundled binary in `bin/` → `PATH`. If all -three fail, show a notification with an install command rather than failing silently. - -### 10.3 Packaging - -Platform-specific VSIX via `vsce package --target ` for `win32-x64`, -`win32-arm64`, `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. GoReleaser -produces the binaries; a CI matrix copies the right one into `bin/` before packaging. -The Marketplace serves each user only their platform's ~6 MB payload. - -### 10.4 Activation and settings - -Activation: `onLanguage:jsonc`, plus `workspaceContains:**/.devcontainer/devcontainer.json` -and `workspaceContains:**/.devcontainer.json`. - -| Setting | Default | Description | -| --- | --- | --- | -| `dcx.enable` | `true` | Master switch | -| `dcx.path` | `""` | Override binary location | -| `dcx.run` | `onSave` | `onSave` \| `onType` | -| `dcx.online` | `false` | Enable network rules | -| `dcx.configPath` | `""` | Explicit config file | -| `dcx.trace.server` | `off` | LSP tracing | - -Commands: *Lint Workspace*, *Fix All Auto-fixable Problems*, *Restart Server*, -*Show Output*. - ---- - -## 11. Testing Strategy - -| Layer | Approach | -| --- | --- | -| **Parser** | Table-driven unit tests; Go native fuzzing (`FuzzParse`) asserting no panic and that offsets stay within bounds | -| **Rules** | Golden-file tests: `testdata/rules//.jsonc` with `// want: error: …` annotations inline, in the style of Go's `analysistest`. The annotation sits on the line the diagnostic must target, so span correctness is tested implicitly. | -| **Schema translation** | Snapshot tests over the rewritten message for each error class | -| **CLI** | End-to-end tests over `testdata/projects/*` asserting stdout, stderr, and exit code | -| **Formatters** | Golden files; SARIF output validated against the SARIF 2.1.0 schema | -| **Corpus** | A vendored set of ~200 real `devcontainer.json` files harvested from public repos. CI asserts zero panics and snapshots the aggregate diagnostic counts — a diff in that snapshot forces a deliberate review of any rule change's blast radius. | -| **Fixes** | Every fixable rule has a `.jsonc` / `.fixed.jsonc` pair; the test applies fixes and asserts the result, then re-lints to assert convergence | -| **Extension** | `@vscode/test-electron` integration test asserting diagnostics appear for a fixture workspace | - -The corpus test is the single highest-value item here: it is the difference between -"the rule works on my example" and "the rule does not produce a wall of false -positives on real-world configs". - ---- - -## 12. Distribution - -| Channel | Mechanism | -| --- | --- | -| GitHub Releases | GoReleaser, 6 platform archives + checksums + SBOM | -| `go install` | `go install github.com/lonhutt/dcx/cmd/dcx@latest` | -| Homebrew | Tap, updated by GoReleaser | -| Scoop | Bucket, updated by GoReleaser | -| Linux packages | `.deb`, `.rpm`, `.apk` via GoReleaser's nfpm | -| Docker | Distroless image, `ghcr.io/lonhutt/dcx` | -| pre-commit | `.pre-commit-hooks.yaml` with a `golang` hook and a binary-download hook | -| GitHub Action | Composite action wrapping the binary, uploading SARIF | -| VSCode | Marketplace + Open VSX, platform-specific VSIX | - -### 12.1 Versioning policy - -Semantic versioning. Rule IDs are public API: - -- **Patch** — bug fixes, message improvements, fewer false positives. -- **Minor** — new rules (may cause new findings; release notes list them), new flags. -- **Major** — rule removal or rename, severity promotion to `error`, exit-code changes. - ---- - -## 13. Milestones - -| # | Milestone | Content | Exit criterion | -| --- | --- | --- | --- | -| **M0** | Foundations | Repo, `go.mod`, CI (test/lint/build matrix), `position`, `vfs`, `diagnostic` packages | CI green on all 6 platforms | -| **M1** | JSONC parser | Lexer, parser, CST, comment retention, error recovery, fuzzing | Parses the 200-file corpus with zero panics | -| **M2** | Schema layer | Vendored schemas, `go:embed`, 2019-09 validation, scenario discrimination, error translation | Every schema error class yields a human-readable message with an exact span | -| **M3** | Rule engine | `Rule` interface, registry, concurrent execution, config file, inline suppressions | Engine runs with a trivial rule set; suppressions tested | -| **M4** | Core rules | `syntax/`, `schema/`, `scenario/`, `semantic/`, `fs/`, `deprecation/` — 31 rules | Golden tests pass for each | -| **M5** | CLI | Discovery, all flags, `text`/`compact`/`json` output, exit codes | End-to-end tests pass; usable by hand | -| **M6** | Extended rules | `feature/` (offline), `port/`, `mount/`, `lifecycle/`, `security/`, `repro/`, `style/`, plus `meta/`, the four offline `vscode/` rules and the `policy` source — 37 rules | Corpus false-positive review complete | -| **M7** | Reporters + release | SARIF, GitHub annotations, GoReleaser, Homebrew, Docker, pre-commit, GH Action | `v0.1.0` published and installable | -| **M8** | VSCode extension v1 | CLI-driven diagnostics, binary bundling, platform VSIX, settings | Published to Marketplace + Open VSX | -| **M9** | Network rules | OCI feature resolution, cache, `--online`, option validation; `openvsx` and `vscode-gallery` sources and the three network `vscode/` rules (3 rules) | Feature option errors detected against real registries; Open VSX portability gap detected on a known-proprietary extension | -| **M10** | Fixes | `Fix` on fixable rules, `--fix`, `--fix-dry-run`, convergence tests | All rules marked *fixable* apply cleanly | -| **M11** | LSP server | `cmd/dcx`, diagnostics, code actions, hover, completion, links | Works in VSCode and Neovim | -| **M12** | Extension v2 | Switch to `LanguageClient`, delete Phase 1 path | Feature parity plus hover/completion | - -M0–M7 constitute a genuinely useful, releasable tool. Everything after is additive. - ---- - -## 14. Resolved Decisions - -Every question from the review draft is now closed. - -| # | Question | Resolution | -| --- | --- | --- | -| 1 | Compose validation depth | **Accepted.** Parse `docker-compose.yml` for the `services` key list only. No Compose semantics, no interpolation, no `extends`. Backs `scenario/compose-service-not-found`. | -| 2 | Does `extensions` honour `publisher.ext@1.2.3`? | **Deferred** → [D1](#15-deferred-backlog). Until verified, `vscode/extension-version-not-allowed` compares against policy pins only. | -| 3 | Gallery authentication | **Offline `policy` source only for now.** No OAuth, and no `token-command` helper in v1. Tracked as [D2](#15-deferred-backlog), low priority. | -| 4 | Auto-discover `extensions.allowed` | **Dropped — there is no valid in-repo location.** See §5.8.2. The finding instead produced a new rule, `vscode/ineffective-application-setting`. | -| 5 | Dockerfile cross-checks | **Accepted.** Exactly one check — `build.args` keys against `ARG` declarations — and explicitly nothing more. Ships as `semantic/build-arg-not-declared`. | -| 6 | `devcontainer-feature.json` linting | **Not in v1.** Tracked as [D3](#15-deferred-backlog). | -| 7 | Rule ID scheme | **`category/kebab-name`.** No numeric aliases. | -| 8 | Config file format | **YAML and JSON only.** No TOML, no bespoke format, nothing else. | - ---- - -## 15. Deferred Backlog - -Tracked work that is deliberately outside v1. Each is independently schedulable and -none blocks another. - -### D1 — Verify `@version` support in the `extensions` array — *low* - -VS Code's `extensions.allowed` policy definitively supports pinned and -platform-qualified versions (`"5.0.0@win32-x64"`). Whether a devcontainer's own -`customizations.vscode.extensions` array accepts a `@version` suffix is unverified. - -- **Do:** test against the Dev Containers extension and the reference CLI; read how - the extension list is passed to the install step. -- **If supported:** extend `vscode/extension-version-not-allowed` to check the - requested pin against policy, and add a `repro/unpinned-extension` rule. -- **If not:** add a rule warning that a `@version` suffix is silently ignored. -- **Blocked by:** nothing. **Blocks:** nothing. - -### D2 — Credential helper for gallery authentication — *low* - -Today a `vscode-gallery` source takes a token via `token-env` only. Some users will -want `token-command: gh auth token` so no long-lived token sits in the environment. - -- **Do:** add `token-command` to the source schema; execute it, trim, treat a - non-zero exit as an unreachable source (warning, not error). -- **Explicitly still out of scope:** OAuth against - `extensions.gallery.authProvider`. That decision does not get revisited here. -- **Blocked by:** M9. **Blocks:** nothing. - -### D3 — Lint `devcontainer-feature.json` — *medium, post-v1* - -A natural second target reusing the entire pipeline: parser, schema layer, rule -engine, reporters, and CLI all apply unchanged. - -- **Shape:** `dcx feature ./src/my-feature`, with a `feature/*` schema - vendored alongside the devcontainer schema and a new rule namespace. -- **Candidate rules:** required `id`/`version`/`name`; `id` matches the directory - name; semver `version`; option `default` satisfies its own `enum`; `dependsOn` and - `installsAfter` reference resolvable features; `install.sh` exists and is - executable; `deprecated` features declare a replacement. -- **Why it fits:** the architecture already separates document kind from rule - registry, so this is a new schema plus a new namespace, not a new tool. -- **Blocked by:** M7 (stable rule engine and reporters). **Blocks:** nothing. - ---- - -## 16. Summary of Key Decisions - -| Decision | Choice | Reason | -| --- | --- | --- | -| Language | Go | Single binary, ~5 ms startup; the extension is a thin client either way | -| Parser | Hand-written JSONC CST | No Go library preserves comments *and* positions *and* recovers from errors | -| Schema | Vendored + `go:embed`, `santhosh-tekuri/jsonschema/v6` | Offline-deterministic; draft 2019-09 + `unevaluatedProperties` | -| Error quality | Discriminate scenario *before* validating | Turns `oneOf` noise into actionable prose — the core value of the project | -| Filesystem | `vfs.FS` abstraction everywhere | Unsaved editor buffers are the whole reason an LSP needs it | -| Positions | Byte offsets internally, converted at the edge | Terminal carets and LSP UTF-16 from one source of truth | -| Fixes | `Diagnostic.Fix` from rule #1 | Retrofitting fixes means rewriting every rule | -| Network | Off by default, degrades to warning | A linter that fails on a flaky registry gets disabled | -| Extension sources | Open VSX default; Marketplace opt-in | Marketplace ToS restricts offerings to Visual Studio products | -| Enterprise path | Consume VS Code's `extensions.allowed` verbatim | Offline, no auth, no new syntax — the org already wrote it | -| Source config | Definitions layered user+project; `required` project-only | Adding your own registry is personal; what the repo must support is a team decision | -| LSP location | `dcx serve`, same binary | One artefact to bundle, install, and version | -| Extension | Phase 1 CLI-driven, Phase 2 LSP | Ships value early; validates the JSON contract | diff --git a/extensions/vscode/.gitkeep b/extensions/vscode/.gitkeep deleted file mode 100644 index bfcc1b1..0000000 --- a/extensions/vscode/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -VS Code extension sources land here (DCL-46). diff --git a/go.mod b/go.mod deleted file mode 100644 index 4c3513b..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/lonhutt/dcx - -go 1.27.1 diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..36139b1 --- /dev/null +++ b/index.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +const args = Bun.argv.slice(2); + +if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + console.log(` +🚀 My Custom Bun CLI + +Usage: + dcx Greets a user by name + dcx --version, -v Shows current version + dcx --help, -h Shows this help menu + `); + process.exit(0); +} + +if (args.includes("--version") || args.includes("-v")) { + console.log("1.0.0"); + process.exit(0); +} + +const name = args[0]; +console.log(`✨ Hello, ${name}! Welcome to your Bun-powered CLI.`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5bd12b --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "dcx", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest", + "prettier": "3.9.6" + }, + "peerDependencies": { + "typescript": "^7" + }, + "scripts": { + "build": "bun build ./index.ts --compile --minify --sourcemap --bytecode --outfile dist/dcx", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "bun test" + }, + "dependencies": { + "ajv": "^8.20.0", + "jsonc-parser": "^3.3.1", + "yaml": "^2.9.0" + } +} diff --git a/pkg/diagnostic/doc.go b/pkg/diagnostic/doc.go deleted file mode 100644 index 36e73ce..0000000 --- a/pkg/diagnostic/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package diagnostic defines the value type every rule produces: severity, source range, message, and an optional machine-applicable fix. -package diagnostic diff --git a/pkg/discovery/doc.go b/pkg/discovery/doc.go deleted file mode 100644 index cf73845..0000000 --- a/pkg/discovery/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package discovery locates devcontainer.json files using the precedence order defined by the Development Container Specification. -package discovery diff --git a/pkg/doc.go b/pkg/doc.go deleted file mode 100644 index 41685d6..0000000 --- a/pkg/doc.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package dcx documents the public API surface of dcx. -// -// Everything under pkg/ is public, semver-stable API. Rule IDs, the Diagnostic -// shape, and the exported signatures of these packages are a compatibility -// contract: they may gain additions in a minor release, but they do not change -// meaning or disappear outside a major release. Code under cmd/ is not part of -// that contract. -// -// This package contains no code. It exists to state the guarantee in one place -// that godoc will surface. -// -// The layering is: -// -// jsonc -> parse source into a concrete syntax tree with byte-exact spans -// model -> lower that tree into a typed model and pick the scenario -// schema -> validate, and translate validator output into readable prose -// rules -> run the rule registry over the document -// report -> render the resulting diagnostics -// -// with position, vfs, and diagnostic as the shared vocabulary beneath all of it. -package dcx diff --git a/pkg/features/doc.go b/pkg/features/doc.go deleted file mode 100644 index 9ef64ec..0000000 --- a/pkg/features/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package features parses Dev Container Feature references and resolves their metadata from OCI registries, with on-disk caching. -package features diff --git a/pkg/jsonc/doc.go b/pkg/jsonc/doc.go deleted file mode 100644 index 2f3cff2..0000000 --- a/pkg/jsonc/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package jsonc lexes and parses JSON with Comments into a concrete syntax tree that retains comments, trailing commas, duplicate keys, and byte-exact spans. -package jsonc diff --git a/pkg/lint/doc.go b/pkg/lint/doc.go deleted file mode 100644 index 48d9921..0000000 --- a/pkg/lint/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package lint is the facade the CLI, the language server, and the editor extension all call. It exposes Analyze over a Document. -package lint diff --git a/pkg/lintconfig/doc.go b/pkg/lintconfig/doc.go deleted file mode 100644 index d00ebd9..0000000 --- a/pkg/lintconfig/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package lintconfig loads and merges .dcx.yaml configuration. YAML and JSON are the only supported formats. -package lintconfig diff --git a/pkg/model/doc.go b/pkg/model/doc.go deleted file mode 100644 index df42de8..0000000 --- a/pkg/model/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package model lowers a JSONC syntax tree into a typed semantic model and discriminates the container-source scenario before schema validation runs. -package model diff --git a/pkg/position/bench_test.go b/pkg/position/bench_test.go deleted file mode 100644 index 99511f4..0000000 --- a/pkg/position/bench_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package position_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/lonhutt/dcx/pkg/position" -) - -// These exist to answer one question if it is ever raised: is the column scan -// worth optimising? Measured first, optimised only if the numbers say so. -// -// For scale: the worst line in the 86 KB fixture is 186 characters, so the scan -// from line start to offset is bounded by that regardless of file size. - -func BenchmarkNew(b *testing.B) { - src := loadFixture(b) - b.ReportAllocs() - b.SetBytes(int64(len(src))) - for b.Loop() { - _ = position.New(src) - } -} - -func BenchmarkUTF16(b *testing.B) { - src := loadFixture(b) - ix := position.New(src) - off := position.Offset(len(src) / 2) - b.ReportAllocs() - for b.Loop() { - _, _ = ix.UTF16(off) - } -} - -func loadFixture(tb testing.TB) []byte { - tb.Helper() - src, err := os.ReadFile(filepath.Join("testdata", "large-commented.jsonc")) - if err != nil { - tb.Fatalf("read fixture: %v", err) - } - return src -} diff --git a/pkg/position/doc.go b/pkg/position/doc.go deleted file mode 100644 index ef6ea45..0000000 --- a/pkg/position/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package position converts between byte offsets, UTF-8 line/column pairs for terminal output, and UTF-16 code unit positions for the Language Server Protocol. -package position diff --git a/pkg/position/fuzz_test.go b/pkg/position/fuzz_test.go deleted file mode 100644 index f2c2d63..0000000 --- a/pkg/position/fuzz_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package position_test - -import ( - "testing" - - "github.com/lonhutt/dcx/pkg/position" -) - -// FuzzLineIndex asserts the two properties that must hold for arbitrary bytes: -// nothing panics, and every result stays in bounds and round trips. -// -// Malformed UTF-8 is a normal input for a linter, not an exotic one. The oracle -// and the implementation agree on it as long as both advance one byte per -// invalid byte, which is what utf8.DecodeRune does when it returns RuneError. -func FuzzLineIndex(f *testing.F) { - for _, seed := range []string{ - "", - "{}", - "a\n", - "a\r\nb\rc", - mixed, - "😀\n中\né", - "a\xffb", // invalid byte - "\xe4", // truncated multi-byte sequence - "\ufeff{}\n", // BOM - } { - f.Add([]byte(seed)) - } - - f.Fuzz(func(t *testing.T, src []byte) { - ix := position.New(src) - orc := newOracle(src) - - if n := ix.LineCount(); n < 1 { - t.Fatalf("LineCount() = %d, must be at least 1 even for empty input", n) - } - if got, want := ix.LineCount(), orc.lineCount(); got != want { - t.Fatalf("LineCount() = %d, oracle says %d", got, want) - } - - for _, off := range runeBoundaries(src) { - // Checked against the oracle as well as through its own inverse: a - // defect shared by UTF8 and OffsetUTF8 satisfies the round trip while - // still producing the wrong column. - wantLine, wantCol := orc.utf8At(off) - l, c := ix.UTF8(position.Offset(off)) - if l != wantLine || c != wantCol { - t.Fatalf("UTF8(%d) = (%d,%d), oracle says (%d,%d)", off, l, c, wantLine, wantCol) - } - if back := ix.OffsetUTF8(l, c); back != position.Offset(off) { - t.Fatalf("utf8 round trip: offset %d -> (%d,%d) -> %d", off, l, c, back) - } - - line, char := ix.UTF16(position.Offset(off)) - if line < 0 || line >= ix.LineCount() { - t.Fatalf("UTF16(%d) line = %d, out of range [0,%d)", off, line, ix.LineCount()) - } - if char < 0 { - t.Fatalf("UTF16(%d) char = %d, negative", off, char) - } - - if back := ix.OffsetUTF16(line, char); back != position.Offset(off) { - t.Fatalf("round trip: offset %d -> (%d,%d) -> %d", off, line, char, back) - } - - // Agreement with the reference implementation must hold here too, - // not just on the curated corpus. - if wl, wc := orc.utf16At(off); wl != line || wc != char { - t.Fatalf("UTF16(%d) = (%d,%d), oracle says (%d,%d)", off, line, char, wl, wc) - } - } - }) -} diff --git a/pkg/position/oracle_test.go b/pkg/position/oracle_test.go deleted file mode 100644 index 4fe3491..0000000 --- a/pkg/position/oracle_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package position_test - -import ( - "unicode/utf16" - "unicode/utf8" -) - -// This file is the reference implementation the real one is tested against. -// -// The per-offset arithmetic here is deliberately the slowest, most obviously -// correct code that could work: it re-encodes the line prefix through []rune and -// utf16.Encode on every call, because that is the *definition* of an LSP column -// rather than an optimisation of it. Nothing in that path should ever be made -// clever. Its only job is to be so simple that when it disagrees with -// pkg/position, the bug is in pkg/position. -// -// The one concession is that line splitting is hoisted into newOracle instead of -// being redone per call. That is not cleverness, it is the difference between a -// 0.3s test and a 30s one on the 86 KB fixture: splitting is O(n) and was being -// run once per offset, making the whole check O(n^2). - -type oracle struct { - src []byte - starts []int // byte offset at which each line begins -} - -// newOracle splits src into lines, treating "\n", "\r\n" and a lone "\r" as -// terminators — which is what VS Code and other LSP clients do. If this -// disagrees with the real implementation, every position after the first stray -// carriage return is silently wrong. -func newOracle(src []byte) *oracle { - starts := []int{0} - for i := 0; i < len(src); { - switch src[i] { - case '\n': - i++ - starts = append(starts, i) - case '\r': - i++ - if i < len(src) && src[i] == '\n' { - i++ - } - starts = append(starts, i) - default: - i++ - } - } - // A source ending in a terminator has a final, empty line whose start offset - // equals len(src). That line is addressable and must not be dropped. - return &oracle{src: src, starts: starts} -} - -func (o *oracle) lineCount() int { return len(o.starts) } - -// line returns the 0-based line containing off. -func (o *oracle) line(off int) int { - line := 0 - for i, s := range o.starts { - if s > off { - break - } - line = i - } - return line -} - -// utf8At returns the 0-based line and the column measured in runes. -func (o *oracle) utf8At(off int) (line, col int) { - line = o.line(off) - return line, utf8.RuneCount(o.src[o.starts[line]:off]) -} - -// utf16At returns the 0-based line and the column measured in UTF-16 code units, -// which is what the Language Server Protocol means by Position.character. -// -// Invalid UTF-8 becomes one U+FFFD per bad byte here, which is also how -// utf8.DecodeRune advances, so the two agree even on malformed input. -func (o *oracle) utf16At(off int) (line, char int) { - line = o.line(off) - return line, len(utf16.Encode([]rune(string(o.src[o.starts[line]:off])))) -} - -// runeBoundaries returns every offset in [0, len(src)] that names a position. -// -// Offsets inside a multi-byte rune have no meaningful column and cannot round -// trip, so tests must not assert on them. The interior of a "\r\n" pair is -// excluded for the same reason: the terminator is one unit of line structure, -// and LSP columns are measured over a line's visible text, which stops before -// it. An offset between the two bytes therefore names no column at all. -func runeBoundaries(src []byte) []int { - offs := make([]int, 0, len(src)+1) - for i := 0; i < len(src); { - if src[i] == '\n' && i > 0 && src[i-1] == '\r' { - i++ - continue - } - offs = append(offs, i) - // Advance by what DecodeRune consumes, not by one byte with a RuneStart - // filter. The two agree on valid UTF-8, but inside a truncated sequence - // DecodeRune yields a one-byte RuneError per continuation byte, and those - // offsets are reachable positions the filter would skip. - _, size := utf8.DecodeRune(src[i:]) - i += size - } - return append(offs, len(src)) // EOF is always a valid position -} diff --git a/pkg/position/position.go b/pkg/position/position.go deleted file mode 100644 index 14c7cda..0000000 --- a/pkg/position/position.go +++ /dev/null @@ -1,214 +0,0 @@ -package position - -import ( - "slices" - "unicode/utf16" - "unicode/utf8" -) - -// Offset is a byte offset into a document. It is the single coordinate the rest -// of dcx passes around; line/column pairs exist only at the edges, where a -// terminal or an LSP client demands them. -type Offset int - -// Range is a half-open byte span, [Start, End). Start equal to End is an empty -// range that still points somewhere meaningful — the caret position for an -// insertion, for instance. -type Range struct { - Start, End Offset -} - -// LineIndex maps byte offsets to line/column pairs for one document. Build it -// once per document with New. -// -// It retains src rather than copying it, so src must not be mutated while the -// index is in use. -type LineIndex struct { - src []byte - lineStarts []int // byte offset at which each line begins -} - -// New indexes src by line, treating "\n", "\r\n" and a lone "\r" as terminators, -// which is what VS Code and other LSP clients do. -// -// A document ending in a terminator has a final, empty line whose start offset -// equals len(src). The line count is therefore always at least one, and EOF is -// always an addressable position — every node's end offset can land there. -func New(src []byte) *LineIndex { - lineStarts := []int{0} - for i := 0; i < len(src); { - switch src[i] { - case '\n': - i++ - lineStarts = append(lineStarts, i) - case '\r': - i++ - if i < len(src) && src[i] == '\n' { - i++ // consume the \n of a \r\n pair - } - lineStarts = append(lineStarts, i) - default: - i++ - } - } - return &LineIndex{src: src, lineStarts: lineStarts} -} - -// clampLine constrains line to one that exists. Design §4.2 invariant 1 forbids -// panicking below cmd/, and an editor naming a line a previous edit deleted is -// routine traffic rather than a programming error. -func (ix *LineIndex) clampLine(line int) int { - if line < 0 { - return 0 - } - if line >= len(ix.lineStarts) { - return len(ix.lineStarts) - 1 - } - return line -} - -// clampOffset constrains off to [0, len(src)]. The upper bound is inclusive -// because EOF is an addressable position. -func (ix *LineIndex) clampOffset(off Offset) int { - if off < 0 { - return 0 - } - if int(off) > len(ix.src) { - return len(ix.src) - } - return int(off) -} - -// LineCount returns the number of lines, which is at least 1 even for an empty -// document. -func (ix *LineIndex) LineCount() int { return len(ix.lineStarts) } - -// LineStart returns the byte offset at which line begins. -// -// line is clamped to a line that exists, so an out-of-range value returns the -// first or last line's start rather than panicking. -func (ix *LineIndex) LineStart(line int) Offset { - return Offset(ix.lineStarts[ix.clampLine(line)]) -} - -// Line returns the 0-based line containing off, in O(log lines). -// -// BinarySearch reports the index at which off would be inserted. A hit means off -// is exactly a line start; a miss means off falls inside the preceding line, -// hence i-1. off is clamped to [0, len(src)] first, so a stale offset from -// before an edit resolves to the first or last line instead of panicking. -func (ix *LineIndex) Line(off Offset) int { - i, found := slices.BinarySearch(ix.lineStarts, ix.clampOffset(off)) - if found { - return i - } - return i - 1 -} - -// LineRange returns the byte range of line, excluding its terminator, so a -// diagnostic drawn over a whole line does not wrap into the next one. Callers -// that want the terminator included use LineStart(line+1) as the end. -// -// line is clamped to a line that exists. -func (ix *LineIndex) LineRange(line int) Range { - line = ix.clampLine(line) - start := ix.lineStarts[line] - - // The last line runs to EOF; every other line runs to the next line's start, - // which sits just past that line's terminator. - end := len(ix.src) - if line+1 < len(ix.lineStarts) { - end = ix.lineStarts[line+1] - } - - // Back up over the terminator. These are two sequential ifs rather than an - // else-if because "\r\n" has to shed both bytes. The end > start guard keeps - // an empty line from backing up past its own start into an inverted range. - if end > start && ix.src[end-1] == '\n' { - end-- - } - if end > start && ix.src[end-1] == '\r' { - end-- - } - - return Range{Start: Offset(start), End: Offset(end)} -} - -// UTF8 returns the 0-based line containing off and the column measured in runes, -// which is what a terminal renderer needs to place a caret under the right -// character. -// -// Invalid UTF-8 counts one rune per bad byte, matching how utf8.DecodeRune -// advances, so columns stay consistent on malformed input. -// -// An offset past the line's visible end — the interior of a "\r\n" pair is the -// only way to reach one — clamps to that end, so the column always converts back -// through OffsetUTF8 to the offset this returned it for. -func (ix *LineIndex) UTF8(off Offset) (line, col int) { - o := ix.clampOffset(off) - line = ix.Line(Offset(o)) - o = min(o, int(ix.LineRange(line).End)) - return line, utf8.RuneCount(ix.src[ix.lineStarts[line]:o]) -} - -// UTF16 returns the 0-based line containing off and the column measured in -// UTF-16 code units, which is what the Language Server Protocol means by -// Position.character. -// -// The difference from UTF8 is not cosmetic: an astral-plane character such as an -// emoji is one rune but two code units, so the two columns diverge after it. -// -// As with UTF8, an offset inside a "\r\n" pair clamps to the line's visible end, -// keeping the column within what OffsetUTF16 will accept. -// -// Ranging over string(...) here does not allocate. The compiler special-cases a -// []byte-to-string conversion used directly as a range expression; assigning it -// to a variable first would cost a copy. -func (ix *LineIndex) UTF16(off Offset) (line, char int) { - o := ix.clampOffset(off) - line = ix.Line(Offset(o)) - o = min(o, int(ix.LineRange(line).End)) - for _, r := range string(ix.src[ix.lineStarts[line]:o]) { - char += utf16.RuneLen(r) - } - return line, char -} - -// OffsetUTF8 converts a (line, rune column) pair back into a byte offset. It is -// the inverse of UTF8, completing the arrow Design §5.2 specifies in both -// directions. -// -// As with OffsetUTF16, a col past the end of the line clamps to that line's -// visible end, so the returned offset always belongs to the line asked for. -func (ix *LineIndex) OffsetUTF8(line, col int) Offset { - lr := ix.LineRange(line) - i, end := int(lr.Start), int(lr.End) - for col > 0 && i < end { - _, size := utf8.DecodeRune(ix.src[i:end]) - i += size - col-- - } - return Offset(i) -} - -// OffsetUTF16 converts an LSP position back into a byte offset. It is the -// inverse of UTF16, and the half DCL-55 depends on: didChange delivers UTF-16 -// ranges that must become offsets before text can be spliced. -// -// A char landing inside a surrogate pair resolves to the offset just past the -// character containing it. -// -// A char past the end of the line clamps to that line's visible end, per the LSP -// rule that an overlong character defaults back to the line length. The scan is -// bounded by LineRange rather than by the document, so the returned offset always -// belongs to the line that was asked for. -func (ix *LineIndex) OffsetUTF16(line, char int) Offset { - lr := ix.LineRange(line) - i, end := int(lr.Start), int(lr.End) - for char > 0 && i < end { - r, size := utf8.DecodeRune(ix.src[i:end]) - i += size - char -= utf16.RuneLen(r) - } - return Offset(i) -} diff --git a/pkg/position/position_test.go b/pkg/position/position_test.go deleted file mode 100644 index 3e1a12e..0000000 --- a/pkg/position/position_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package position_test - -import ( - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/lonhutt/dcx/pkg/position" -) - -// mixed exercises all four width classes in one line. -// -// byte: 0 1 2 3 4 5 6 7 8 9 10 11 12 -// a ' ' é(2) ' ' 中(3) ' ' 😀(4) -// -// The last row of the table below is the one that matters: at end of line the -// rune column is 7 but the UTF-16 column is 8, because the emoji is a surrogate -// pair. Any implementation that conflates the two passes every other row. -const mixed = "a é 中 😀" - -func TestColumnsAtRuneBoundaries(t *testing.T) { - ix := position.New([]byte(mixed)) - - for _, tc := range []struct { - off int - wantRune int - wantUTF16 int - what string - }{ - {0, 0, 0, "start"}, - {1, 1, 1, "after 'a'"}, - {2, 2, 2, "start of é"}, - {4, 3, 3, "after é (2 bytes, 1 unit)"}, - {5, 4, 4, "start of 中"}, - {8, 5, 5, "after 中 (3 bytes, 1 unit)"}, - {9, 6, 6, "start of 😀"}, - {13, 7, 8, "after 😀 — 4 bytes, 1 rune, TWO utf-16 units"}, - } { - gotLine, gotCol := ix.UTF8(position.Offset(tc.off)) - if gotLine != 0 || gotCol != tc.wantRune { - t.Errorf("UTF8(%d) [%s] = (%d,%d), want (0,%d)", - tc.off, tc.what, gotLine, gotCol, tc.wantRune) - } - - gotLine, gotChar := ix.UTF16(position.Offset(tc.off)) - if gotLine != 0 || gotChar != tc.wantUTF16 { - t.Errorf("UTF16(%d) [%s] = (%d,%d), want (0,%d)", - tc.off, tc.what, gotLine, gotChar, tc.wantUTF16) - } - } -} - -func TestLineStructure(t *testing.T) { - for _, tc := range []struct { - name string - src string - wantLines int - }{ - {"empty is one line", "", 1}, - {"no trailing newline", "a\nb", 2}, - {"trailing newline adds an empty final line", "a\n", 2}, - {"two trailing newlines", "a\n\n", 3}, - {"crlf", "a\r\nb", 2}, - {"lone cr is a terminator", "a\rb", 2}, - {"mixed terminators", "a\r\nb\nc\rd", 4}, - {"only a newline", "\n", 2}, - } { - t.Run(tc.name, func(t *testing.T) { - ix := position.New([]byte(tc.src)) - if got := ix.LineCount(); got != tc.wantLines { - t.Errorf("LineCount(%q) = %d, want %d", tc.src, got, tc.wantLines) - } - // EOF must always be addressable: every node's end offset can land there. - if line, _ := ix.UTF16(position.Offset(len(tc.src))); line != tc.wantLines-1 { - t.Errorf("UTF16(EOF) line = %d, want last line %d", line, tc.wantLines-1) - } - }) - } -} - -// TestAgainstOracle is the acceptance criterion: for every rune boundary in the -// document, the real implementation must agree with the reference one, and the -// UTF-16 position must convert back to the byte offset it came from. -func TestAgainstOracle(t *testing.T) { - for _, tc := range []struct{ name, src string }{ - {"empty", ""}, - {"ascii", "{\n \"name\": \"x\"\n}\n"}, - {"latin1", "é\néé\n"}, - {"cjk", "中文\n中\n"}, - {"astral", "😀\n😀😀\n"}, - {"mixed", mixed}, - {"crlf", "a\r\nb\r\n"}, - {"lone cr", "a\rb\r"}, - {"emoji then ascii on same line", "😀abc\n"}, - {"invalid utf8", "a\xffb\n\xe4\n"}, - {"bom", "\ufeff{}\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - checkAgainstOracle(t, []byte(tc.src)) - }) - } -} - -// TestAgainstOracleLargeFixture runs the same comparison over a real 86 KB -// devcontainer.json with 1,885 non-ASCII characters and 9 astral-plane emoji -// spread across 1,558 lines. Hand-written cases prove the arithmetic; this -// proves it survives reality. -func TestAgainstOracleLargeFixture(t *testing.T) { - src, err := os.ReadFile(filepath.Join("testdata", "large-commented.jsonc")) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - checkAgainstOracle(t, src) -} - -func checkAgainstOracle(t *testing.T, src []byte) { - t.Helper() - ix := position.New(src) - orc := newOracle(src) - - // The line tables must agree before any per-offset comparison means much: a - // differing line count means the two are indexing different documents, and - // every column below would be checked against the wrong line. - if got, want := ix.LineCount(), orc.lineCount(); got != want { - t.Fatalf("LineCount() = %d, oracle says %d", got, want) - } - - for _, off := range runeBoundaries(src) { - wantLine, wantCol := orc.utf8At(off) - gotLine, gotCol := ix.UTF8(position.Offset(off)) - if gotLine != wantLine || gotCol != wantCol { - t.Fatalf("UTF8(%d) = (%d,%d), oracle says (%d,%d)\n%s", - off, gotLine, gotCol, wantLine, wantCol, context(src, off)) - } - - // The UTF-8 direction round trips too: Design §5.2 specifies the arrow - // both ways, for a terminal renderer that has a column and needs a span. - if back := ix.OffsetUTF8(gotLine, gotCol); back != position.Offset(off) { - t.Fatalf("OffsetUTF8(%d,%d) = %d, want %d (round trip)\n%s", - gotLine, gotCol, back, off, context(src, off)) - } - - wantLine, wantChar := orc.utf16At(off) - gotLine, gotChar := ix.UTF16(position.Offset(off)) - if gotLine != wantLine || gotChar != wantChar { - t.Fatalf("UTF16(%d) = (%d,%d), oracle says (%d,%d)\n%s", - off, gotLine, gotChar, wantLine, wantChar, context(src, off)) - } - - // Round trip. This is the half that DCL-55 depends on: didChange sends - // UTF-16 ranges that must become byte offsets before text can be spliced. - if back := ix.OffsetUTF16(gotLine, gotChar); back != position.Offset(off) { - t.Fatalf("OffsetUTF16(%d,%d) = %d, want %d (round trip)\n%s", - gotLine, gotChar, back, off, context(src, off)) - } - } -} - -// context renders the neighbourhood of a failing offset, because "want 41 got 42" -// is not enough to debug a column bug. -func context(src []byte, off int) string { - lo, hi := off-20, off+20 - if lo < 0 { - lo = 0 - } - if hi > len(src) { - hi = len(src) - } - var b strings.Builder - b.WriteString(" context: ") - b.WriteString(strconv.Quote(string(src[lo:off]))) - b.WriteString(" ") - b.WriteString(strconv.Quote(string(src[off:hi]))) - return b.String() -} - -// TestLineRange pins the terminator semantics: a line's Range covers its visible -// text and stops before the terminator, so a diagnostic drawn over a whole line -// does not wrap the selection into the next one. Callers that want the -// terminator included ask for LineStart(line+1) instead. -// -// The cases that matter are CRLF (the terminator is two bytes, not one) and the -// empty lines, where backing up over a terminator must not run past the line -// start and produce an inverted range. -func TestLineRange(t *testing.T) { - for _, tc := range []struct { - name string - src string - line int - want string // the text the returned range must cover - }{ - {"lf: first line", "a\nb", 0, "a"}, - {"lf: last line has no terminator", "a\nb", 1, "b"}, - {"crlf backs up two bytes", "a\r\nb", 0, "a"}, - {"crlf: second line", "a\r\nb", 1, "b"}, - {"lone cr is a terminator", "a\rb", 0, "a"}, - {"empty document is one empty line", "", 0, ""}, - {"trailing lf leaves an empty final line", "a\n", 1, ""}, - {"empty line between terminators", "a\n\nb", 1, ""}, - {"only a terminator", "\n", 0, ""}, - {"multi-byte runes: offsets are bytes", "中文\n", 0, "中文"}, - {"astral", "😀\n", 0, "😀"}, - } { - t.Run(tc.name, func(t *testing.T) { - ix := position.New([]byte(tc.src)) - r := ix.LineRange(tc.line) - if r.Start > r.End { - t.Fatalf("LineRange(%d) = %+v: start is past end", tc.line, r) - } - if got := tc.src[r.Start:r.End]; got != tc.want { - t.Errorf("LineRange(%d) = %+v covering %q, want %q", - tc.line, r, got, tc.want) - } - }) - } -} - -// TestOffsetUTF16Clamps pins the LSP rule that a character past the end of a -// line defaults back to the line length, where "length" is the visible text and -// excludes the terminator. -// -// The scan used to be bounded by the end of the *document*, so an overlong -// character walked on into the following lines and returned an offset belonging -// to a line the caller never asked about. Clients are permitted to send exactly -// that, and incremental didChange (Design §9.2) turns the result straight into a -// splice point. -func TestOffsetUTF16Clamps(t *testing.T) { - for _, tc := range []struct { - name string - src string - line, char int - want position.Offset - }{ - {"char past end of line stops at visible end", "a\nbbbb\n", 0, 3, 1}, - {"char far past end", "a\nbbbb\n", 0, 99, 1}, - {"char exactly at visible end", "a\nbbbb\n", 0, 1, 1}, - {"the terminator itself is not addressable", "a\nbbbb\n", 0, 2, 1}, - {"middle line clamps to its own end", "a\nbbbb\n", 1, 99, 6}, - {"last line without a terminator", "a\nb", 1, 99, 3}, - {"empty final line", "a\n", 1, 5, 2}, - {"crlf sheds both bytes", "a\r\nb", 0, 5, 1}, - {"astral: one rune is two units", "😀x\n", 0, 2, 4}, - {"astral: clamp past end", "😀x\n", 0, 99, 5}, - } { - t.Run(tc.name, func(t *testing.T) { - ix := position.New([]byte(tc.src)) - if got := ix.OffsetUTF16(tc.line, tc.char); got != tc.want { - t.Errorf("OffsetUTF16(%d, %d) on %q = %d, want %d", - tc.line, tc.char, tc.src, got, tc.want) - } - }) - } -} - -// TestOffsetUTF8Clamps is TestOffsetUTF16Clamps' counterpart: a rune column past -// the end of a line resolves to that line's visible end, never into the next -// line's text. -func TestOffsetUTF8Clamps(t *testing.T) { - for _, tc := range []struct { - name string - src string - line, col int - want position.Offset - }{ - {"col past end of line stops at visible end", "a\nbbbb\n", 0, 3, 1}, - {"col far past end", "a\nbbbb\n", 0, 99, 1}, - {"col exactly at visible end", "a\nbbbb\n", 0, 1, 1}, - {"middle line clamps to its own end", "a\nbbbb\n", 1, 99, 6}, - {"last line without a terminator", "a\nb", 1, 99, 3}, - {"empty final line", "a\n", 1, 5, 2}, - {"crlf sheds both bytes", "a\r\nb", 0, 5, 1}, - {"astral: one rune is one column", "😀x\n", 0, 1, 4}, - {"multi-byte: columns are runes, offsets are bytes", "中文\n", 0, 1, 3}, - {"negative col is the line start", "a\nbbbb\n", 1, -3, 2}, - } { - t.Run(tc.name, func(t *testing.T) { - ix := position.New([]byte(tc.src)) - if got := ix.OffsetUTF8(tc.line, tc.col); got != tc.want { - t.Errorf("OffsetUTF8(%d, %d) on %q = %d, want %d", - tc.line, tc.col, tc.src, got, tc.want) - } - }) - } -} - -// TestOutOfRangeLinesClamp pins Design §4.2 invariant 1: nothing below cmd/ -// panics. A didChange naming a line that a previous edit deleted is normal -// editor traffic, not malformed input. -// -// The policy is to clamp — line into [0, LineCount()) — matching what the two -// Offset* methods already do with an overlong column. -func TestOutOfRangeLinesClamp(t *testing.T) { - const src = "a\nbb\n" // 3 lines: "a", "bb", "" - ix := position.New([]byte(src)) - - for _, tc := range []struct { - name string - call func() any - want any - }{ - {"LineStart negative", func() any { return ix.LineStart(-5) }, position.Offset(0)}, - {"LineStart past end", func() any { return ix.LineStart(99) }, position.Offset(5)}, - {"LineRange negative", func() any { return ix.LineRange(-5) }, position.Range{Start: 0, End: 1}}, - {"LineRange past end", func() any { return ix.LineRange(99) }, position.Range{Start: 5, End: 5}}, - {"Line negative offset", func() any { return ix.Line(-5) }, 0}, - {"Line offset past EOF", func() any { return ix.Line(99) }, 2}, - {"OffsetUTF16 negative line", func() any { return ix.OffsetUTF16(-5, 0) }, position.Offset(0)}, - {"OffsetUTF16 line past end", func() any { return ix.OffsetUTF16(99, 0) }, position.Offset(5)}, - {"OffsetUTF8 negative line", func() any { return ix.OffsetUTF8(-5, 0) }, position.Offset(0)}, - {"OffsetUTF8 line past end", func() any { return ix.OffsetUTF8(99, 0) }, position.Offset(5)}, - } { - t.Run(tc.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("panicked: %v", r) - } - }() - if got := tc.call(); got != tc.want { - t.Errorf("= %v, want %v", got, tc.want) - } - }) - } -} - -// TestOutOfRangeOffsetsClamp is the same invariant for the offset-taking -// conversions. A byte offset from a stale parse can outlive the edit that -// shortened the document. -func TestOutOfRangeOffsetsClamp(t *testing.T) { - const src = "a\nbb\n" - ix := position.New([]byte(src)) - - for _, tc := range []struct { - name string - off position.Offset - wantLine, wantCol int - }{ - {"negative offset is the document start", -5, 0, 0}, - {"offset past EOF is the last line", 99, 2, 0}, - } { - t.Run(tc.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("panicked: %v", r) - } - }() - if l, c := ix.UTF8(tc.off); l != tc.wantLine || c != tc.wantCol { - t.Errorf("UTF8(%d) = (%d,%d), want (%d,%d)", tc.off, l, c, tc.wantLine, tc.wantCol) - } - if l, c := ix.UTF16(tc.off); l != tc.wantLine || c != tc.wantCol { - t.Errorf("UTF16(%d) = (%d,%d), want (%d,%d)", tc.off, l, c, tc.wantLine, tc.wantCol) - } - }) - } -} - -// TestCRLFInteriorClamps covers the one offset class runeBoundaries deliberately -// does not enumerate: a position between the two bytes of a "\r\n" pair. -// -// It is a rune boundary, so a caller can hand it to UTF8 or UTF16, but it lies -// past the line's visible end. It therefore clamps there, and the column it -// yields converts back to that clamped offset — rather than to a column its own -// inverse would reject, which is what it did before. -// -// The oracle cannot check this: it measures columns over a raw line prefix and -// has no notion of clamping, which is exactly why it is simple enough to trust. -func TestCRLFInteriorClamps(t *testing.T) { - for _, tc := range []struct { - name string - src string - off int - wantLine int - wantCol, wantChar int // rune column, UTF-16 column - wantBack position.Offset - }{ - {"between cr and lf", "a\r\nb", 2, 0, 1, 1, 1}, - {"crlf at the start of the document", "\r\nb", 1, 0, 0, 0, 0}, - {"crlf on a later line", "a\r\nbb\r\n", 6, 1, 2, 2, 5}, - {"multi-byte rune before the crlf", "中\r\n", 4, 0, 1, 1, 3}, - {"astral before the crlf: the two columns differ", "😀\r\n", 5, 0, 1, 2, 4}, - } { - t.Run(tc.name, func(t *testing.T) { - ix := position.New([]byte(tc.src)) - - line, col := ix.UTF8(position.Offset(tc.off)) - if line != tc.wantLine || col != tc.wantCol { - t.Errorf("UTF8(%d) on %q = (%d,%d), want (%d,%d)", - tc.off, tc.src, line, col, tc.wantLine, tc.wantCol) - } - if back := ix.OffsetUTF8(line, col); back != tc.wantBack { - t.Errorf("OffsetUTF8(%d,%d) = %d, want %d", line, col, back, tc.wantBack) - } - - line, char := ix.UTF16(position.Offset(tc.off)) - if line != tc.wantLine || char != tc.wantChar { - t.Errorf("UTF16(%d) on %q = (%d,%d), want (%d,%d)", - tc.off, tc.src, line, char, tc.wantLine, tc.wantChar) - } - if back := ix.OffsetUTF16(line, char); back != tc.wantBack { - t.Errorf("OffsetUTF16(%d,%d) = %d, want %d", line, char, back, tc.wantBack) - } - }) - } -} diff --git a/pkg/position/testdata/README.md b/pkg/position/testdata/README.md deleted file mode 100644 index ebfc02f..0000000 --- a/pkg/position/testdata/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# position test fixtures - -`large-commented.jsonc` — a real `devcontainer.json` from -`Ilenburg1993/chatgpt-docker-puppeteer`, retrieved 2026-09-04. - -It is here because it is the widest gap between bytes and characters found in a -survey of public dev container configs: 86,007 bytes but 82,799 characters, with -1,885 non-ASCII characters and 9 astral-plane emoji across 1,558 lines. Every one -of those emoji is 4 UTF-8 bytes and 2 UTF-16 code units, which is exactly the case -a naive column implementation gets wrong. - -Median real-world devcontainer.json is about 1.7 KB; this is the p100 tail. diff --git a/pkg/registry/doc.go b/pkg/registry/doc.go deleted file mode 100644 index bddcf2a..0000000 --- a/pkg/registry/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package registry resolves VS Code extension availability against Open VSX, a vscode-gallery service, or an offline extensions.allowed policy. -package registry diff --git a/pkg/report/doc.go b/pkg/report/doc.go deleted file mode 100644 index 74c767c..0000000 --- a/pkg/report/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package report formats diagnostics as text, compact, JSON, SARIF, or GitHub workflow annotations. -package report diff --git a/pkg/rules/doc.go b/pkg/rules/doc.go deleted file mode 100644 index 5357ec4..0000000 --- a/pkg/rules/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package rules defines the Rule interface every check implements, and the -// engine that runs the registry concurrently over a single document. -package rules diff --git a/pkg/rules/engine.go b/pkg/rules/engine.go deleted file mode 100644 index 1e8c7fa..0000000 --- a/pkg/rules/engine.go +++ /dev/null @@ -1 +0,0 @@ -package rules diff --git a/pkg/rules/rule.go b/pkg/rules/rule.go deleted file mode 100644 index 1e8c7fa..0000000 --- a/pkg/rules/rule.go +++ /dev/null @@ -1 +0,0 @@ -package rules diff --git a/pkg/schema/doc.go b/pkg/schema/doc.go deleted file mode 100644 index f371061..0000000 --- a/pkg/schema/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package schema validates documents against the embedded Development Container JSON Schema and translates validator output into human-readable diagnostics. -package schema diff --git a/pkg/suppress/doc.go b/pkg/suppress/doc.go deleted file mode 100644 index 22575ad..0000000 --- a/pkg/suppress/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package suppress parses inline dcx-disable directives from JSONC comments. -package suppress diff --git a/pkg/vfs/doc.go b/pkg/vfs/doc.go deleted file mode 100644 index c5e1ac9..0000000 --- a/pkg/vfs/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package vfs abstracts filesystem access so rules work identically against disk and against unsaved editor buffers. -package vfs diff --git a/schemas b/schemas new file mode 120000 index 0000000..67f28d0 --- /dev/null +++ b/schemas @@ -0,0 +1 @@ +devcontainer-spec/schemas \ No newline at end of file diff --git a/schemas/.gitkeep b/schemas/.gitkeep deleted file mode 100644 index 8c36ccb..0000000 --- a/schemas/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Vendored upstream JSON schemas land here (DCL-12), embedded via go:embed. diff --git a/scripts/build-validators.ts b/scripts/build-validators.ts new file mode 100644 index 0000000..07f7da1 --- /dev/null +++ b/scripts/build-validators.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun + +import Ajv2019 from "ajv/dist/2019"; +import Ajv from "ajv"; +import standaloneCode from "ajv/dist/standalone/index.js"; +import path from "path"; +import baseSchema from "../schemas/devContainer.base.schema.json" with { type: "json" }; +import devcontainerFeatureSchema from "../schemas/devContainerFeature.schema.json" with { type: "json" }; + +/** + * Compiles standalone Ajv validator modules from `schemas/*.schema.json` into + * `src/schema/generated/`, at build time rather than via `new Function` at startup. + * Output is committed; it's a build-time input, not a runtime artifact. + * + * Two Ajv instances, not one: `devContainer.base.schema.json` declares + * `$schema: .../draft/2019-09/schema` and `devContainerFeature.schema.json` declares + * draft-07 — they're genuinely different dialects, not a case where either entrypoint + * happens to work. Using the default `ajv` entrypoint (draft-07) for the base schema + * doesn't silently ignore `unevaluatedProperties` the way the naive version of this + * mistake usually does — it fails to even recognize the 2019-09 `$schema` URI and + * throws immediately (`no schema with key or ref ".../2019-09/schema"`). + * + * `strict: false` on both: the schemas use `allowComments`/`allowTrailingCommas`, + * which Ajv's strict mode rejects as unknown keywords otherwise. + * + * `devContainer.schema.json` (the wrapper) is deliberately NOT compiled here. It's an + * `allOf` of a local relative `$ref` to the base schema (which Ajv won't resolve + * without `addSchema`-registering the base schema under a matching key first) plus two + * `https://raw.githubusercontent.com/...` refs to VS Code's own schemas, which were + * never vendored into `schemas/` at all. Making it compile means either vendoring those + * two files or deciding to drop them from the wrapper — a real scope decision, not + * something to paper over here with a permissive ref loader. + */ +export async function buildValidators(): Promise { + const ajv2019 = new Ajv2019({ + code: { source: true, esm: true }, + allErrors: true, + strict: false, + }); + const ajv07 = new Ajv({ code: { source: true, esm: true }, allErrors: true, strict: false }); + + const base = ajv2019.compile(baseSchema); + const feature = ajv07.compile(devcontainerFeatureSchema); + + // TODO: both schemas use `format: "uri"`, and without `ajv-formats` Ajv ignores it + // (it warns `unknown format "uri" ignored` at compile time). Adding it would be a + // fourth runtime dependency past jsonc-parser, ajv and yaml; decide before relying + // on format validation. + + const outDir = path.join(import.meta.dir, "..", "src", "schema", "generated"); + await Bun.write( + path.join(outDir, "devContainer.base.validator.js"), + standaloneCode(ajv2019, base), + ); + await Bun.write( + path.join(outDir, "devContainerFeature.validator.js"), + standaloneCode(ajv07, feature), + ); +} + +if (import.meta.main) { + await buildValidators(); +} diff --git a/src/cli/cli.ts b/src/cli/cli.ts new file mode 100644 index 0000000..36139b1 --- /dev/null +++ b/src/cli/cli.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +const args = Bun.argv.slice(2); + +if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + console.log(` +🚀 My Custom Bun CLI + +Usage: + dcx Greets a user by name + dcx --version, -v Shows current version + dcx --help, -h Shows this help menu + `); + process.exit(0); +} + +if (args.includes("--version") || args.includes("-v")) { + console.log("1.0.0"); + process.exit(0); +} + +const name = args[0]; +console.log(`✨ Hello, ${name}! Welcome to your Bun-powered CLI.`); diff --git a/src/discovery/discovery.test.ts b/src/discovery/discovery.test.ts new file mode 100644 index 0000000..378a86c --- /dev/null +++ b/src/discovery/discovery.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BunFS } from "../vfs/bunfs"; +import { OverlayFS } from "../vfs/overlayfs"; +import { discoverDevcontainer } from "./discovery"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +/** Creates a temp project containing `files` (paths relative to its root) and returns the root. */ +function project(...files: string[]): string { + const root = mkdtempSync(join(tmpdir(), "dcx-discovery-")); + roots.push(root); + for (const file of files) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), "{}"); + } + return root; +} + +const disk = new BunFS(); + +test.each([ + [ + "prefers .devcontainer/devcontainer.json over every other location", + ".devcontainer/devcontainer.json", + ], + ["falls back to .devcontainer.json before subfolders", ".devcontainer.json"], +])("%s", async (_, winner) => { + const all = [ + ".devcontainer/devcontainer.json", + ".devcontainer.json", + ".devcontainer/a/devcontainer.json", + ]; + const root = project(...all.slice(all.indexOf(winner))); + expect((await discoverDevcontainer(disk, root)).targets).toEqual([join(root, winner)]); +}); + +test("returns every one-level subfolder config, sorted, and ignores deeper ones", async () => { + const root = project( + ".devcontainer/b/devcontainer.json", + ".devcontainer/a/devcontainer.json", + ".devcontainer/c/deep/devcontainer.json", + ); + expect((await discoverDevcontainer(disk, root)).targets).toEqual([ + join(root, ".devcontainer/a/devcontainer.json"), + join(root, ".devcontainer/b/devcontainer.json"), + ]); +}); + +test("a repo with three nested configs lints all three", async () => { + const root = project( + ".devcontainer/a/devcontainer.json", + ".devcontainer/b/devcontainer.json", + ".devcontainer/c/devcontainer.json", + ); + expect((await discoverDevcontainer(disk, root)).targets).toEqual([ + join(root, ".devcontainer/a/devcontainer.json"), + join(root, ".devcontainer/b/devcontainer.json"), + join(root, ".devcontainer/c/devcontainer.json"), + ]); +}); + +test("skips a directory named devcontainer.json", async () => { + const root = project(".devcontainer/devcontainer.json/placeholder", ".devcontainer.json"); + expect((await discoverDevcontainer(disk, root)).targets).toEqual([ + join(root, ".devcontainer.json"), + ]); +}); + +test("returns a file target as-is", async () => { + const root = project("configs/custom.json"); + const result = await discoverDevcontainer(disk, join(root, "configs/custom.json")); + expect(result.targets).toEqual([join(root, "configs/custom.json")]); + expect(result.searched).toEqual([join(root, "configs/custom.json")]); +}); + +test("returns no targets when nothing is found, and names the target itself when it doesn't exist", async () => { + const root = project("README.md"); + const missing = join(root, "missing"); + + const noConfig = await discoverDevcontainer(disk, root); + expect(noConfig.targets).toEqual([]); + expect(noConfig.searched).toEqual([ + join(root, ".devcontainer", "devcontainer.json"), + join(root, ".devcontainer.json"), + ]); + + const noTarget = await discoverDevcontainer(disk, missing); + expect(noTarget.targets).toEqual([]); + expect(noTarget.searched).toEqual([missing]); +}); + +test("treats a .devcontainer file as absent rather than throwing", async () => { + const root = project(".devcontainer"); + const result = await discoverDevcontainer(disk, root); + expect(result.targets).toEqual([]); + expect(result.searched).toEqual([ + join(root, ".devcontainer", "devcontainer.json"), + join(root, ".devcontainer.json"), + ]); +}); + +test("not finding a subfolder config still lists every subfolder candidate searched", async () => { + const root = project(".devcontainer/a/placeholder", ".devcontainer/b/placeholder"); + const result = await discoverDevcontainer(disk, root); + expect(result.targets).toEqual([]); + expect(result.searched).toEqual([ + join(root, ".devcontainer", "devcontainer.json"), + join(root, ".devcontainer.json"), + join(root, ".devcontainer", "a", "devcontainer.json"), + join(root, ".devcontainer", "b", "devcontainer.json"), + ]); +}); + +// The reason discovery takes a FileSystem at all: in the LSP a config can exist +// only as an unsaved buffer, and discovery still has to find it. +test("finds a config that exists only as an overlay buffer", async () => { + const root = project(".devcontainer/placeholder"); + const overlay = new OverlayFS(disk); + const path = join(root, ".devcontainer", "devcontainer.json"); + overlay.set(path, "{}"); + expect((await discoverDevcontainer(overlay, root)).targets).toEqual([path]); +}); + +test("a buffer does not resurrect a config deleted from disk", async () => { + const root = project(".devcontainer/devcontainer.json"); + const overlay = new OverlayFS(disk); + await overlay.readFile(join(root, ".devcontainer", "devcontainer.json")); + rmSync(join(root, ".devcontainer", "devcontainer.json")); + expect((await discoverDevcontainer(overlay, root)).targets).toEqual([]); +}); diff --git a/src/discovery/discovery.ts b/src/discovery/discovery.ts new file mode 100644 index 0000000..1087596 --- /dev/null +++ b/src/discovery/discovery.ts @@ -0,0 +1,85 @@ +import { join, resolve } from "node:path"; +import type { FileSystem } from "../vfs/vfs"; + +/** + * The result of resolving a `dcx check` target. + * + * `searched` lists every devcontainer.json candidate path discovery actually checked, + * in the order tried, so the CLI's exit-2 message can name what it looked at when + * `targets` comes back empty. + */ +export interface DiscoveryResult { + readonly targets: readonly string[]; + readonly searched: readonly string[]; +} + +/** + * Resolves a `dcx check` target to the devcontainer.json file(s) to lint, in the + * discovery order from the Dev Container spec. + * + * A file target is returned as-is. A directory is searched for + * `.devcontainer/devcontainer.json`, then `.devcontainer.json`, and the first + * that exists wins. Failing both, every `.devcontainer//devcontainer.json` + * exactly one level deep is returned. + * + * Absence is a normal result, not an exception: the CLI turns an empty `targets` + * into exit 2, using `searched` to name what it looked at. I/O errors + * other than a missing path still throw. + * + * @param fs Injected rather than constructed, so the LSP discovers through its + * own `OverlayFS` and sees unsaved buffers. + * @param target File or directory, absolute or relative to the working directory. + * @returns `targets` sorted, empty when no config is found; `searched` is every + * candidate path checked along the way. + */ +export async function discoverDevcontainer( + fs: FileSystem, + target = process.cwd(), +): Promise { + const root = resolve(target); + const stats = await fs.stat(root); + if (!stats) return { targets: [], searched: [root] }; + if (stats.kind === "file") return { targets: [root], searched: [root] }; + + const searched: string[] = []; + + // Sequential, not Promise.all: the first hit wins, so the second lookup is wasted work. + for (const candidate of [ + join(root, ".devcontainer", "devcontainer.json"), + join(root, ".devcontainer.json"), + ]) { + searched.push(candidate); + if (await isFile(fs, candidate)) return { targets: [candidate], searched }; + } + + const dir = join(root, ".devcontainer"); + if ((await fs.stat(dir))?.kind !== "directory") return { targets: [], searched }; + + // readDir lists in codepoint order and neither step below reorders, so the result is sorted. + // searched.push happens synchronously before the await in each map callback, and .map + // invokes every callback synchronously in order, so push order matches entry order + // regardless of which isFile call settles first. + const found = await Promise.all( + (await fs.readDir(dir)) + .filter((entry) => entry.kind === "directory") + .map(async (entry) => { + const candidate = join(dir, entry.name, "devcontainer.json"); + searched.push(candidate); + return (await isFile(fs, candidate)) ? candidate : undefined; + }), + ); + + return { targets: found.filter((candidate) => candidate !== undefined), searched }; +} + +/** + * True only for a regular file (following symlinks); a directory named + * `devcontainer.json`, or a path whose parent is a file, is not one. + * + * Needs no error handling of its own: `stat` already reports both absence and + * ENOTDIR as `undefined`, and anything it does reject with is a real I/O + * failure this function has no business swallowing. + */ +async function isFile(fs: FileSystem, path: string): Promise { + return (await fs.stat(path))?.kind === "file"; +} diff --git a/src/position/oracle.test.ts b/src/position/oracle.test.ts new file mode 100644 index 0000000..f4749e3 --- /dev/null +++ b/src/position/oracle.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { LineIndex, type Position, type TerminalPosition } from "./position"; + +// Reference implementation LineIndex is checked against. It's slow on purpose (linear +// scan for the line, subtraction for the character, split on tabs for the column) so +// that when the two disagree, the bug is in LineIndex. Keep it dumb. +// +// Line starts are computed once per document, not per call; otherwise the large fixture +// is O(n²). +// +// Bun.stringWidth is trusted for everything but tabs (it says a tab is 0 wide); glyph +// widths are the library's problem, not ours. + +class Oracle { + readonly starts: number[] = [0]; + + constructor(readonly text: string) { + for (let i = 0; i < text.length; i++) { + if (text[i] === "\r" && text[i + 1] === "\n") i++; + if (text[i] === "\n" || text[i] === "\r") this.starts.push(i + 1); + } + } + + /** What LineIndex should treat `offset` as; clamped and snapped out of a CRLF. */ + canonical(offset: number): number { + const o = Math.min(Math.max(offset, 0), this.text.length); + return this.text[o - 1] === "\r" && this.text[o] === "\n" ? o - 1 : o; + } + + positionAt(offset: number): Position { + const o = this.canonical(offset); + let line = 0; + for (let i = 0; i < this.starts.length; i++) if (this.starts[i]! <= o) line = i; + return { line, character: o - this.starts[line]! }; + } + + terminalPositionAt(offset: number, tabWidth: number): TerminalPosition { + const { line, character } = this.positionAt(offset); + const start = this.starts[line]!; + const segments = this.text.slice(start, start + character).split("\t"); + let width = 0; + segments.forEach((segment, i) => { + width += Bun.stringWidth(segment); + if (i < segments.length - 1) width = (Math.floor(width / tabWidth) + 1) * tabWidth; + }); + return { line: line + 1, column: width + 1 }; + } +} + +/** + * Diffs LineIndex against the oracle at every offset in `text`, round trip included. + * Returns the first mismatch (or undefined) so a failure shows the input, not just + * `expected true`. + */ +function firstMismatch(text: string, tabWidth = 8) { + const ix = new LineIndex(text); + const oracle = new Oracle(text); + if (ix.lineCount !== oracle.starts.length) { + return { text, lineCount: ix.lineCount, want: oracle.starts.length }; + } + for (let offset = 0; offset <= text.length; offset++) { + const position = ix.positionAt(offset); + const wantPosition = oracle.positionAt(offset); + if (position.line !== wantPosition.line || position.character !== wantPosition.character) { + return { text, offset, position, want: wantPosition }; + } + const back = ix.offsetAt(position); + if (back !== oracle.canonical(offset)) { + return { text, offset, position, roundTrip: back, want: oracle.canonical(offset) }; + } + const terminal = ix.terminalPositionAt(offset, tabWidth); + const wantTerminal = oracle.terminalPositionAt(offset, tabWidth); + if (terminal.line !== wantTerminal.line || terminal.column !== wantTerminal.column) { + return { text, offset, tabWidth, terminal, want: wantTerminal }; + } + } + return undefined; +} + +const fixture = (name: string) => Bun.file(join(import.meta.dir, "testdata", name)).text(); + +describe("LineIndex agrees with the oracle at every offset", () => { + test.each<[string, string]>([ + ["empty", ""], + ["ascii", '{\n "image": "ubuntu"\n}\n'], + ["bmp", "é中ア\ńx"], + ["astral", "😀\n👨‍👩‍👧 🇯🇵\n"], + ["every terminator", "a\r\nb\nc\rd\r\r\n\n"], + ["tabs", "\ta\t\tb\n中\t😀\tx"], + ])("%s", (_, text) => { + expect(firstMismatch(text)).toBeUndefined(); + }); + + test("mixed-script fixture", async () => { + const text = await fixture("mixed-script.jsonc"); + for (const tabWidth of [1, 2, 4, 8]) expect(firstMismatch(text, tabWidth)).toBeUndefined(); + }); + + test("large real-world fixture", async () => { + expect(firstMismatch(await fixture("large-commented.jsonc"))).toBeUndefined(); + }); +}); + +// Bun has no coverage-guided fuzzer, so this is a seeded loop instead. The +// pieces are weighted toward what breaks position code (terminators, tabs, surrogates, +// zero and double width glyphs); uniform Unicode would be almost all plain letters. +describe("property: random documents", () => { + const pieces = [ + "a", + "Z", + " ", + "{", + '"', + "\n", + "\r\n", + "\r", + "\t", + "中", + "ア", + "́", + "😀", + "👨‍👩‍👧", + "🇯🇵", + "\uD83D", // lone surrogate; malformed, but a string (or a file) can still hold one + ]; + + /** mulberry32; a tiny seeded PRNG, so a failure reproduces from the seed. */ + function rng(seed: number) { + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + test("every offset converts, and converts back, for 500 seeded documents", () => { + for (let seed = 1; seed <= 500; seed++) { + const random = rng(seed); + const length = Math.floor(random() * 40); + const text = Array.from({ length }, () => pieces[Math.floor(random() * pieces.length)]).join( + "", + ); + const tabWidth = [1, 2, 4, 8][Math.floor(random() * 4)]!; + const mismatch = firstMismatch(text, tabWidth); + expect(mismatch && { seed, ...mismatch }).toBeUndefined(); + } + }); +}); diff --git a/src/position/position.test.ts b/src/position/position.test.ts new file mode 100644 index 0000000..f6175db --- /dev/null +++ b/src/position/position.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { assertNoSplitSurrogate, LineIndex, type Range } from "./position"; + +describe("line structure", () => { + test.each<[string, string, number]>([ + ["empty is one line", "", 1], + ["no trailing newline", "a\nb", 2], + ["trailing newline adds an empty final line", "a\n", 2], + ["two trailing newlines", "a\n\n", 3], + ["crlf", "a\r\nb", 2], + ["lone cr is a terminator", "a\rb", 2], + ["mixed terminators", "a\r\nb\nc\rd", 4], + ["only a newline", "\n", 2], + ["cr then crlf is two terminators", "\r\r\n", 3], + ])("%s", (_, text, lines) => { + const ix = new LineIndex(text); + expect(ix.lineCount).toBe(lines); + // EOF has to be addressable; any node's end offset can land there + expect(ix.lineAt(text.length)).toBe(lines - 1); + }); + + test("lineStart of each line in a mixed-terminator document", () => { + const ix = new LineIndex("a\r\nb\nc\rd"); + expect([0, 1, 2, 3].map((l) => ix.lineStart(l))).toEqual([0, 3, 5, 7]); + }); +}); + +describe("lineRange is the visible extent, excluding the terminator", () => { + test.each<[string, string, number, string]>([ + ["lf: first line", "a\nb", 0, "a"], + ["lf: last line has no terminator", "a\nb", 1, "b"], + ["crlf sheds both units", "a\r\nb", 0, "a"], + ["crlf: second line", "a\r\nb", 1, "b"], + ["lone cr is a terminator", "a\rb", 0, "a"], + ["empty document is one empty line", "", 0, ""], + ["trailing lf leaves an empty final line", "a\n", 1, ""], + ["empty line between terminators", "a\n\nb", 1, ""], + ["only a terminator", "\n", 0, ""], + ["cjk", "中文\n", 0, "中文"], + ["astral", "😀\n", 0, "😀"], + ])("%s", (_, text, line, want) => { + const r = new LineIndex(text).lineRange(line); + expect(r.start).toBeLessThanOrEqual(r.end); + expect(text.slice(r.start, r.end)).toBe(want); + }); +}); + +describe("positionAt", () => { + test("offsets are UTF-16 code units, so an emoji advances character by 2", () => { + const ix = new LineIndex("😀x"); + expect(ix.positionAt(2)).toEqual({ line: 0, character: 2 }); + expect(ix.positionAt(3)).toEqual({ line: 0, character: 3 }); + }); + + test("the offset of a terminator is the end of its own line", () => { + const ix = new LineIndex("a\nb"); + expect(ix.positionAt(1)).toEqual({ line: 0, character: 1 }); + expect(ix.positionAt(2)).toEqual({ line: 1, character: 0 }); + }); + + test("an offset inside a surrogate pair is reported as-is, not snapped", () => { + expect(new LineIndex("😀").positionAt(1)).toEqual({ line: 0, character: 1 }); + }); +}); + +// A character past the end of a line falls back to the visible length. Clamping to the +// end of the document instead lets it walk into the next line, and an LSP incremental +// didChange would splice there. +describe("offsetAt clamps character to the line's visible end", () => { + test.each<[string, string, number, number, number]>([ + ["past end of line stops at visible end", "a\nbbbb\n", 0, 3, 1], + ["far past end", "a\nbbbb\n", 0, 99, 1], + ["exactly at visible end", "a\nbbbb\n", 0, 1, 1], + ["the terminator itself is not addressable", "a\nbbbb\n", 0, 2, 1], + ["middle line clamps to its own end", "a\nbbbb\n", 1, 99, 6], + ["last line without a terminator", "a\nb", 1, 99, 3], + ["empty final line", "a\n", 1, 5, 2], + ["crlf sheds both units", "a\r\nb", 0, 5, 1], + ["astral: one code point is two units", "😀x\n", 0, 2, 2], + ["astral: clamp past end", "😀x\n", 0, 99, 3], + ["negative character is the line start", "a\nbb", 1, -3, 2], + ])("%s", (_, text, line, character, want) => { + expect(new LineIndex(text).offsetAt({ line, character })).toBe(want); + }); +}); + +// An offset between "\r" and "\n" isn't a real position; it snaps back to the "\r", and +// the result round-trips to that snapped offset. +describe("an offset inside a CRLF pair snaps to the CR", () => { + test.each<[string, string, number, number, number, number]>([ + ["between cr and lf", "a\r\nb", 2, 0, 1, 1], + ["crlf at the start of the document", "\r\nb", 1, 0, 0, 0], + ["crlf on a later line", "a\r\nbb\r\n", 6, 1, 2, 5], + ["astral before the crlf", "😀\r\n", 3, 0, 2, 2], + ["crlf after a lone cr", "\r\r\n", 2, 1, 0, 1], + ])("%s", (_, text, offset, line, character, back) => { + const ix = new LineIndex(text); + expect(ix.positionAt(offset)).toEqual({ line, character }); + expect(ix.offsetAt({ line, character })).toBe(back); + expect(ix.terminalPositionAt(offset).line).toBe(line + 1); + }); +}); + +// nothing throws; a stale offset can outlive the edit that shortened the document, and +// an LSP client can send any line it wants +describe("out-of-range input clamps instead of throwing", () => { + const ix = new LineIndex("a\nbb\n"); // 3 lines: "a", "bb", "" + + test.each<[string, () => unknown, unknown]>([ + ["lineStart negative", () => ix.lineStart(-5), 0], + ["lineStart past end", () => ix.lineStart(99), 5], + ["lineRange negative", () => ix.lineRange(-5), { start: 0, end: 1 }], + ["lineRange past end", () => ix.lineRange(99), { start: 5, end: 5 }], + ["lineAt negative offset", () => ix.lineAt(-5), 0], + ["lineAt offset past EOF", () => ix.lineAt(99), 2], + ["positionAt negative offset", () => ix.positionAt(-5), { line: 0, character: 0 }], + ["positionAt offset past EOF", () => ix.positionAt(99), { line: 2, character: 0 }], + ["offsetAt negative line", () => ix.offsetAt({ line: -5, character: 0 }), 0], + ["offsetAt line past end", () => ix.offsetAt({ line: 99, character: 0 }), 5], + ["terminalPositionAt negative", () => ix.terminalPositionAt(-5), { line: 1, column: 1 }], + ["terminalPositionAt past EOF", () => ix.terminalPositionAt(99), { line: 3, column: 1 }], + ])("%s", (_, call, want) => { + expect(call).not.toThrow(); + expect(call()).toEqual(want); + }); +}); + +// "😀" is 2 code units, 1 code point and 2 columns. Units matching columns is a +// coincidence; the ZWJ family below is 8 units, 5 code points and still 2 columns. +describe("terminalPositionAt measures display width", () => { + test.each<[string, string, number, number]>([ + ["ascii", "abc", 2, 3], + ["cjk is two columns each", "中文x", 2, 5], + ["half-width katakana is one column", "アイx", 2, 3], + ["a combining mark is zero columns", "éx", 2, 2], + ["astral emoji", "😀x", 2, 3], + ["zwj family is one 2-column glyph, not a sum of code points", "👨‍👩‍👧x", 8, 3], + ["regional-indicator flag", "🇯🇵x", 4, 3], + ["leading tab advances to the first stop", "\tx", 1, 9], + ["tab after text advances to the next stop", "ab\tx", 3, 9], + ["tab after a wide char", "中\tx", 2, 9], + ["tab at an exact stop advances a full stop", "12345678\tx", 9, 17], + ["two tabs", "\t\t", 2, 17], + ])("%s", (_, text, offset, column) => { + expect(new LineIndex(text).terminalPositionAt(offset)).toEqual({ line: 1, column }); + }); + + test("tabWidth controls the stop", () => { + const ix = new LineIndex("ab\tx"); + expect(ix.terminalPositionAt(3, 4)).toEqual({ line: 1, column: 5 }); + expect(ix.terminalPositionAt(3, 2)).toEqual({ line: 1, column: 5 }); + expect(ix.terminalPositionAt(3, 1)).toEqual({ line: 1, column: 4 }); + }); + + test("width restarts on each line, and the line is 1-based", () => { + expect(new LineIndex("中中\n中x").terminalPositionAt(4)).toEqual({ line: 2, column: 3 }); + }); +}); + +describe("assertNoSplitSurrogate", () => { + const text = "a😀b"; // units: a, \uD83D, \uDE00, b + + test.each<[string, Range]>([ + ["whole text", { start: 0, end: 4 }], + ["exactly the emoji", { start: 1, end: 3 }], + ["empty range at a boundary", { start: 3, end: 3 }], + ["document edges", { start: 0, end: 0 }], + ])("accepts %s", (_, range) => { + expect(() => assertNoSplitSurrogate(text, range)).not.toThrow(); + }); + + test.each<[string, Range]>([ + ["start inside the pair", { start: 2, end: 4 }], + ["end inside the pair", { start: 0, end: 2 }], + ["empty range inside the pair", { start: 2, end: 2 }], + ])("rejects %s", (_, range) => { + // match the message; otherwise any throw passes (a stub's included) + expect(() => assertNoSplitSurrogate(text, range)).toThrow(/surrogate/i); + }); + + test("a lone surrogate has no pair to split", () => { + expect(() => assertNoSplitSurrogate("\uD83Dx", { start: 1, end: 2 })).not.toThrow(); + expect(() => assertNoSplitSurrogate("x\uDE00", { start: 1, end: 2 })).not.toThrow(); + }); +}); diff --git a/src/position/position.ts b/src/position/position.ts new file mode 100644 index 0000000..62cac37 --- /dev/null +++ b/src/position/position.ts @@ -0,0 +1,157 @@ +/** + * Coordinates for the whole tool. + * + * Everything internal is a UTF-16 code-unit {@link Offset}; it's the unit JS strings, + * `jsonc-parser` and LSP `Position` already share. Display columns only exist at the + * terminal edge, and only here (nothing outside `src/position` builds a line/column + * pair by hand). + */ + +/** A UTF-16 code-unit offset into a document's text. */ +export type Offset = number; + +/** A half-open span `[start, end)` of UTF-16 code units. */ +export interface Range { + readonly start: Offset; + readonly end: Offset; +} + +/** LSP-shaped position: 0-based line, 0-based UTF-16 code-unit character. */ +export interface Position { + readonly line: number; + readonly character: number; +} + +/** Terminal-shaped position: 1-based line, 1-based column measured in display width. */ +export interface TerminalPosition { + readonly line: number; + readonly column: number; +} + +const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi); + +const CR = 0x0d; +const LF = 0x0a; + +/** + * Line structure of one document; built once, queried many times. + * + * `\n`, `\r\n` and a lone `\r` all end a line. A trailing terminator leaves an empty + * final line, so EOF is always addressable. Nothing throws; out-of-range input clamps + * into the document (an LSP server has to stay up through stale offsets). + */ +export class LineIndex { + /** Where each line starts. Sorted, `starts[0] === 0`, one entry per line. */ + private readonly starts: Offset[] = [0]; + + /** Records every line-start offset of `text` in a single pass. */ + constructor(private readonly text: string) { + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === CR && text.charCodeAt(i + 1) === LF) i++; + if (c === LF || c === CR) this.starts.push(i + 1); + } + } + + /** Number of lines; always at least 1, even for an empty document. */ + get lineCount(): number { + return this.starts.length; + } + + /** Offset at which `line` begins; `line` clamps to `[0, lineCount - 1]`. */ + lineStart(line: number): Offset { + return this.starts[clamp(line, 0, this.starts.length - 1)]!; + } + + /** 0-based line containing `offset`; `offset` clamps to `[0, text.length]`. */ + lineAt(offset: Offset): number { + const o = clamp(offset, 0, this.text.length); + // last line whose start is <= o. mid rounds up; otherwise `lo = mid` can spin forever + let lo = 0; + let hi = this.starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.starts[mid]! <= o) lo = mid; + else hi = mid - 1; + } + return lo; + } + + /** + * Visible extent of `line`, terminator excluded; a whole-line highlight shouldn't wrap + * into the next one. `line` clamps as in {@link lineStart}. + */ + lineRange(line: number): Range { + const l = clamp(line, 0, this.starts.length - 1); + const start = this.starts[l]!; + let end = l + 1 < this.starts.length ? this.starts[l + 1]! : this.text.length; + if (end > start && this.text.charCodeAt(end - 1) === LF) end--; + if (end > start && this.text.charCodeAt(end - 1) === CR) end--; + return { start, end }; + } + + /** + * LSP position of `offset`. An offset inside a `\r\n` pair snaps back to the `\r`. + * One inside a surrogate pair is left alone; that's {@link assertNoSplitSurrogate}'s job. + */ + positionAt(offset: Offset): Position { + const o = this.canonical(offset); + const line = this.lineAt(o); + return { line, character: o - this.starts[line]! }; + } + + /** + * Inverse of {@link positionAt}. `line` clamps to the document, and a `character` past + * the visible end falls back to it (the LSP convention); never into the terminator. + */ + offsetAt(position: Position): Offset { + if (position.line < 0) return 0; + const { start, end } = this.lineRange(position.line); + return start + clamp(position.character, 0, end - start); + } + + /** + * Terminal position of `offset`, 1-based. Column is display width: wide CJK counts 2, + * combining marks 0, and tabs jump to the next multiple of `tabWidth`. Clamps like + * {@link positionAt}. + */ + terminalPositionAt(offset: Offset, tabWidth = 8): TerminalPosition { + const { line, character } = this.positionAt(offset); + const start = this.starts[line]!; + // Bun.stringWidth says a tab is 0 wide, and a tab's real width depends on where it + // lands; so walk the runs between tabs. Measure each run whole (per code point, a ZWJ + // emoji counts once per member). + const runs = this.text.slice(start, start + character).split("\t"); + let width = 0; + runs.forEach((run, i) => { + width += Bun.stringWidth(run); + if (i < runs.length - 1) width = (Math.floor(width / tabWidth) + 1) * tabWidth; + }); + return { line: line + 1, column: width + 1 }; + } + + /** `offset` clamped into the document and snapped out of a `\r\n` pair. */ + private canonical(offset: Offset): Offset { + const o = clamp(offset, 0, this.text.length); + const insideCrlf = this.text.charCodeAt(o - 1) === CR && this.text.charCodeAt(o) === LF; + return insideCrlf ? o - 1 : o; + } +} + +const isHighSurrogate = (c: number) => c >= 0xd800 && c <= 0xdbff; +const isLowSurrogate = (c: number) => c >= 0xdc00 && c <= 0xdfff; + +/** + * Throws (with "surrogate" in the message) if either end of `range` splits a surrogate + * pair in `text`. A dev-build guard for ranges the parser didn't produce; a lone + * surrogate has nothing to split, so it passes. + */ +export function assertNoSplitSurrogate(text: string, range: Range): void { + for (const o of [range.start, range.end]) { + // charCodeAt, not codePointAt; codePointAt returns the whole pair and hides the half + // we're checking. Out of range is NaN, which fails both checks, so edges are fine. + if (isHighSurrogate(text.charCodeAt(o - 1)) && isLowSurrogate(text.charCodeAt(o))) { + throw new Error(`offset ${o} splits a surrogate pair`); + } + } +} diff --git a/src/position/testdata/README.md b/src/position/testdata/README.md new file mode 100644 index 0000000..9bef0ea --- /dev/null +++ b/src/position/testdata/README.md @@ -0,0 +1,13 @@ +# position test fixtures + +`large-commented.jsonc` is a real `devcontainer.json` from +`Ilenburg1993/chatgpt-docker-puppeteer` (retrieved 2026-09-04). It had the widest gap +between bytes and characters in a survey of public configs; 1,885 non-ASCII +characters and 9 astral emoji across 1,558 lines. The median is about 1.7 KB, so this is +the far tail. + +`mixed-script.jsonc` is hand-built to cover every width class and line terminator in one +file: CJK (2 columns), half-width katakana (1), a combining mark (0), tabs at the start +and middle of a line, an emoji, a ZWJ family (8 code units, 2 columns), a flag, and +`\n`, `\r\n` and a lone `\r`. The exact bytes matter; don't let an editor normalize the +line endings. diff --git a/pkg/position/testdata/large-commented.jsonc b/src/position/testdata/large-commented.jsonc similarity index 100% rename from pkg/position/testdata/large-commented.jsonc rename to src/position/testdata/large-commented.jsonc diff --git a/src/position/testdata/mixed-script.jsonc b/src/position/testdata/mixed-script.jsonc new file mode 100644 index 0000000..9eaac3f --- /dev/null +++ b/src/position/testdata/mixed-script.jsonc @@ -0,0 +1,7 @@ +// mixed-script fixture for src/position — every width class on one page +{ + "name": "開発コンテナ 🐳", + "remoteUser": "rémy", // combining acute: 0 columns + "features": {}, // two tabs mid-line "postCreateCommand": "echo 👨‍👩‍👧 🇯🇵 中文", + "x": "アイウ half-width katakana" +} diff --git a/src/rules/engine.ts b/src/rules/engine.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/rules/rule.ts b/src/rules/rule.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/schema/generated/devContainer.base.validator.js b/src/schema/generated/devContainer.base.validator.js new file mode 100644 index 0000000..03be817 --- /dev/null +++ b/src/schema/generated/devContainer.base.validator.js @@ -0,0 +1 @@ +"use strict";export const validate = validate18;export default validate18;const schema21 = {"$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};const schema25 = {"type":"object","properties":{"image":{"type":"string","description":"The docker image that will be used to create the container."}},"required":["image"]};const schema26 = {"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."}}};const schema27 = {"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"]};const schema22 = {"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"}}}]}]};const schema23 = {"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"}}}};function validate19(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate19.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs0 = errors;let valid0 = false;let passing0 = null;const _errs1 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.build === undefined){const err0 = {instancePath,schemaPath:"#/oneOf/0/required",keyword:"required",params:{missingProperty: "build"},message:"must have required property '"+"build"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.build !== undefined){let data0 = data.build;if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.dockerfile === undefined){const err1 = {instancePath:instancePath+"/build",schemaPath:"#/oneOf/0/properties/build/allOf/0/required",keyword:"required",params:{missingProperty: "dockerfile"},message:"must have required property '"+"dockerfile"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data0.dockerfile !== undefined){if(typeof data0.dockerfile !== "string"){const err2 = {instancePath:instancePath+"/build/dockerfile",schemaPath:"#/oneOf/0/properties/build/allOf/0/properties/dockerfile/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data0.context !== undefined){if(typeof data0.context !== "string"){const err3 = {instancePath:instancePath+"/build/context",schemaPath:"#/oneOf/0/properties/build/allOf/0/properties/context/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}else {const err4 = {instancePath:instancePath+"/build",schemaPath:"#/oneOf/0/properties/build/allOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.target !== undefined){if(typeof data0.target !== "string"){const err5 = {instancePath:instancePath+"/build/target",schemaPath:"#/definitions/buildOptions/properties/target/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data0.args !== undefined){let data4 = data0.args;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){for(const key0 in data4){if(typeof data4[key0] !== "string"){const err6 = {instancePath:instancePath+"/build/args/" + key0.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/definitions/buildOptions/properties/args/additionalProperties/type",keyword:"type",params:{type: schema23.properties.args.additionalProperties.type},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}else {const err7 = {instancePath:instancePath+"/build/args",schemaPath:"#/definitions/buildOptions/properties/args/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data0.cacheFrom !== undefined){let data6 = data0.cacheFrom;if((typeof data6 !== "string") && (!(Array.isArray(data6)))){const err8 = {instancePath:instancePath+"/build/cacheFrom",schemaPath:"#/definitions/buildOptions/properties/cacheFrom/type",keyword:"type",params:{type: schema23.properties.cacheFrom.type},message:"must be string,array"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(Array.isArray(data6)){const len0 = data6.length;for(let i0=0; i0 65535 || isNaN(data11)){const err13 = {instancePath:instancePath+"/forwardPorts/" + i1,schemaPath:"#/properties/forwardPorts/items/oneOf/0/maximum",keyword:"maximum",params:{comparison: "<=", limit: 65535},message:"must be <= 65535"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data11 < 0 || isNaN(data11)){const err14 = {instancePath:instancePath+"/forwardPorts/" + i1,schemaPath:"#/properties/forwardPorts/items/oneOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}var _valid0 = _errs26 === errors;if(_valid0){valid7 = true;passing0 = 0;}const _errs28 = errors;if(typeof data11 === "string"){if(!pattern6.test(data11)){const err15 = {instancePath:instancePath+"/forwardPorts/" + i1,schemaPath:"#/properties/forwardPorts/items/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^([a-z0-9-]+):(\\d{1,5})$"},message:"must match pattern \""+"^([a-z0-9-]+):(\\d{1,5})$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/forwardPorts/" + i1,schemaPath:"#/properties/forwardPorts/items/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}var _valid0 = _errs28 === errors;if(_valid0 && valid7){valid7 = false;passing0 = [passing0, 1];}else {if(_valid0){valid7 = true;passing0 = 1;}}if(!valid7){const err17 = {instancePath:instancePath+"/forwardPorts/" + i1,schemaPath:"#/properties/forwardPorts/items/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}else {errors = _errs25;if(vErrors !== null){if(_errs25){vErrors.length = _errs25;}else {vErrors = null;}}}}}else {const err18 = {instancePath:instancePath+"/forwardPorts",schemaPath:"#/properties/forwardPorts/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.portsAttributes !== undefined){let data12 = data.portsAttributes;if(data12 && typeof data12 == "object" && !Array.isArray(data12)){for(const key4 in data12){if(!(pattern7.test(key4))){const err19 = {instancePath:instancePath+"/portsAttributes",schemaPath:"#/properties/portsAttributes/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}for(const key5 in data12){if(pattern7.test(key5)){let data13 = data12[key5];if(data13 && typeof data13 == "object" && !Array.isArray(data13)){if(data13.onAutoForward !== undefined){let data14 = data13.onAutoForward;if(typeof data14 !== "string"){const err20 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/onAutoForward",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/onAutoForward/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(!((((((data14 === "notify") || (data14 === "openBrowser")) || (data14 === "openBrowserOnce")) || (data14 === "openPreview")) || (data14 === "silent")) || (data14 === "ignore"))){const err21 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/onAutoForward",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/onAutoForward/enum",keyword:"enum",params:{allowedValues: schema28.properties.portsAttributes.patternProperties["(^\\d+(-\\d+)?$)|(.+)"].properties.onAutoForward.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data13.elevateIfNeeded !== undefined){if(typeof data13.elevateIfNeeded !== "boolean"){const err22 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/elevateIfNeeded",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/elevateIfNeeded/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data13.label !== undefined){if(typeof data13.label !== "string"){const err23 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/label",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data13.requireLocalPort !== undefined){if(typeof data13.requireLocalPort !== "boolean"){const err24 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/requireLocalPort",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/requireLocalPort/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data13.protocol !== undefined){let data18 = data13.protocol;if(typeof data18 !== "string"){const err25 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/protocol",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/protocol/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(!((data18 === "http") || (data18 === "https"))){const err26 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1")+"/protocol",schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/properties/protocol/enum",keyword:"enum",params:{allowedValues: schema28.properties.portsAttributes.patternProperties["(^\\d+(-\\d+)?$)|(.+)"].properties.protocol.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}else {const err27 = {instancePath:instancePath+"/portsAttributes/" + key5.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/portsAttributes/patternProperties/(%5E%5Cd%2B(-%5Cd%2B)%3F%24)%7C(.%2B)/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}}else {const err28 = {instancePath:instancePath+"/portsAttributes",schemaPath:"#/properties/portsAttributes/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data.otherPortsAttributes !== undefined){let data19 = data.otherPortsAttributes;if(data19 && typeof data19 == "object" && !Array.isArray(data19)){for(const key6 in data19){if(!(((((key6 === "onAutoForward") || (key6 === "elevateIfNeeded")) || (key6 === "label")) || (key6 === "requireLocalPort")) || (key6 === "protocol"))){const err29 = {instancePath:instancePath+"/otherPortsAttributes",schemaPath:"#/properties/otherPortsAttributes/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key6},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data19.onAutoForward !== undefined){let data20 = data19.onAutoForward;if(typeof data20 !== "string"){const err30 = {instancePath:instancePath+"/otherPortsAttributes/onAutoForward",schemaPath:"#/properties/otherPortsAttributes/properties/onAutoForward/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(!(((((data20 === "notify") || (data20 === "openBrowser")) || (data20 === "openPreview")) || (data20 === "silent")) || (data20 === "ignore"))){const err31 = {instancePath:instancePath+"/otherPortsAttributes/onAutoForward",schemaPath:"#/properties/otherPortsAttributes/properties/onAutoForward/enum",keyword:"enum",params:{allowedValues: schema28.properties.otherPortsAttributes.properties.onAutoForward.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data19.elevateIfNeeded !== undefined){if(typeof data19.elevateIfNeeded !== "boolean"){const err32 = {instancePath:instancePath+"/otherPortsAttributes/elevateIfNeeded",schemaPath:"#/properties/otherPortsAttributes/properties/elevateIfNeeded/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}if(data19.label !== undefined){if(typeof data19.label !== "string"){const err33 = {instancePath:instancePath+"/otherPortsAttributes/label",schemaPath:"#/properties/otherPortsAttributes/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data19.requireLocalPort !== undefined){if(typeof data19.requireLocalPort !== "boolean"){const err34 = {instancePath:instancePath+"/otherPortsAttributes/requireLocalPort",schemaPath:"#/properties/otherPortsAttributes/properties/requireLocalPort/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data19.protocol !== undefined){let data24 = data19.protocol;if(typeof data24 !== "string"){const err35 = {instancePath:instancePath+"/otherPortsAttributes/protocol",schemaPath:"#/properties/otherPortsAttributes/properties/protocol/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(!((data24 === "http") || (data24 === "https"))){const err36 = {instancePath:instancePath+"/otherPortsAttributes/protocol",schemaPath:"#/properties/otherPortsAttributes/properties/protocol/enum",keyword:"enum",params:{allowedValues: schema28.properties.otherPortsAttributes.properties.protocol.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}}else {const err37 = {instancePath:instancePath+"/otherPortsAttributes",schemaPath:"#/properties/otherPortsAttributes/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data.updateRemoteUserUID !== undefined){if(typeof data.updateRemoteUserUID !== "boolean"){const err38 = {instancePath:instancePath+"/updateRemoteUserUID",schemaPath:"#/properties/updateRemoteUserUID/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data.containerEnv !== undefined){let data26 = data.containerEnv;if(data26 && typeof data26 == "object" && !Array.isArray(data26)){for(const key7 in data26){if(typeof data26[key7] !== "string"){const err39 = {instancePath:instancePath+"/containerEnv/" + key7.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/containerEnv/additionalProperties/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}else {const err40 = {instancePath:instancePath+"/containerEnv",schemaPath:"#/properties/containerEnv/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data.containerUser !== undefined){if(typeof data.containerUser !== "string"){const err41 = {instancePath:instancePath+"/containerUser",schemaPath:"#/properties/containerUser/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data.mounts !== undefined){let data29 = data.mounts;if(Array.isArray(data29)){const len2 = data29.length;for(let i2=0; i2=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}}if(data69.memory !== undefined){let data71 = data69.memory;if(typeof data71 === "string"){if(!pattern9.test(data71)){const err92 = {instancePath:instancePath+"/hostRequirements/memory",schemaPath:"#/properties/hostRequirements/properties/memory/pattern",keyword:"pattern",params:{pattern: "^\\d+([tgmk]b)?$"},message:"must match pattern \""+"^\\d+([tgmk]b)?$"+"\""};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}}else {const err93 = {instancePath:instancePath+"/hostRequirements/memory",schemaPath:"#/properties/hostRequirements/properties/memory/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}}if(data69.storage !== undefined){let data72 = data69.storage;if(typeof data72 === "string"){if(!pattern9.test(data72)){const err94 = {instancePath:instancePath+"/hostRequirements/storage",schemaPath:"#/properties/hostRequirements/properties/storage/pattern",keyword:"pattern",params:{pattern: "^\\d+([tgmk]b)?$"},message:"must match pattern \""+"^\\d+([tgmk]b)?$"+"\""};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}else {const err95 = {instancePath:instancePath+"/hostRequirements/storage",schemaPath:"#/properties/hostRequirements/properties/storage/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}}if(data69.gpu !== undefined){let data73 = data69.gpu;const _errs169 = errors;let valid53 = false;let passing1 = null;const _errs170 = errors;if((typeof data73 !== "boolean") && (typeof data73 !== "string")){const err96 = {instancePath:instancePath+"/hostRequirements/gpu",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/0/type",keyword:"type",params:{type: schema28.properties.hostRequirements.properties.gpu.oneOf[0].type},message:"must be boolean,string"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}if(!(((data73 === true) || (data73 === false)) || (data73 === "optional"))){const err97 = {instancePath:instancePath+"/hostRequirements/gpu",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/0/enum",keyword:"enum",params:{allowedValues: schema28.properties.hostRequirements.properties.gpu.oneOf[0].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}var _valid2 = _errs170 === errors;if(_valid2){valid53 = true;passing1 = 0;}const _errs172 = errors;if(data73 && typeof data73 == "object" && !Array.isArray(data73)){for(const key16 in data73){if(!((key16 === "cores") || (key16 === "memory"))){const err98 = {instancePath:instancePath+"/hostRequirements/gpu",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key16},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}if(data73.cores !== undefined){let data74 = data73.cores;if(!((typeof data74 == "number") && (!(data74 % 1) && !isNaN(data74)))){const err99 = {instancePath:instancePath+"/hostRequirements/gpu/cores",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/properties/cores/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}if(typeof data74 == "number"){if(data74 < 1 || isNaN(data74)){const err100 = {instancePath:instancePath+"/hostRequirements/gpu/cores",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/properties/cores/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}}}if(data73.memory !== undefined){let data75 = data73.memory;if(typeof data75 === "string"){if(!pattern9.test(data75)){const err101 = {instancePath:instancePath+"/hostRequirements/gpu/memory",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/properties/memory/pattern",keyword:"pattern",params:{pattern: "^\\d+([tgmk]b)?$"},message:"must match pattern \""+"^\\d+([tgmk]b)?$"+"\""};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}else {const err102 = {instancePath:instancePath+"/hostRequirements/gpu/memory",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/properties/memory/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}}else {const err103 = {instancePath:instancePath+"/hostRequirements/gpu",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}var _valid2 = _errs172 === errors;if(_valid2 && valid53){valid53 = false;passing1 = [passing1, 1];}else {if(_valid2){valid53 = true;passing1 = 1;}}if(!valid53){const err104 = {instancePath:instancePath+"/hostRequirements/gpu",schemaPath:"#/properties/hostRequirements/properties/gpu/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}else {errors = _errs169;if(vErrors !== null){if(_errs169){vErrors.length = _errs169;}else {vErrors = null;}}}}for(const key17 in data69){if((((key17 !== "cpus") && (key17 !== "memory")) && (key17 !== "storage")) && (key17 !== "gpu")){const err105 = {instancePath:instancePath+"/hostRequirements",schemaPath:"#/properties/hostRequirements/unevaluatedProperties",keyword:"unevaluatedProperties",params:{unevaluatedProperty: key17},message:"must NOT have unevaluated properties"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}}}else {const err106 = {instancePath:instancePath+"/hostRequirements",schemaPath:"#/properties/hostRequirements/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}if(data.customizations !== undefined){let data76 = data.customizations;if(!(data76 && typeof data76 == "object" && !Array.isArray(data76))){const err107 = {instancePath:instancePath+"/customizations",schemaPath:"#/properties/customizations/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}if(data.additionalProperties !== undefined){let data77 = data.additionalProperties;if(data77 && typeof data77 == "object" && !Array.isArray(data77)){}else {const err108 = {instancePath:instancePath+"/additionalProperties",schemaPath:"#/properties/additionalProperties/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}}else {const err109 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}validate21.errors = vErrors;return errors === 0;}validate21.evaluated = {"props":{"$schema":true,"name":true,"features":true,"overrideFeatureInstallOrder":true,"secrets":true,"forwardPorts":true,"portsAttributes":true,"otherPortsAttributes":true,"updateRemoteUserUID":true,"containerEnv":true,"containerUser":true,"mounts":true,"init":true,"privileged":true,"capAdd":true,"securityOpt":true,"remoteEnv":true,"remoteUser":true,"initializeCommand":true,"onCreateCommand":true,"updateContentCommand":true,"postCreateCommand":true,"postStartCommand":true,"postAttachCommand":true,"waitFor":true,"userEnvProbe":true,"hostRequirements":true,"customizations":true,"additionalProperties":true},"dynamicProps":false,"dynamicItems":false};function validate18(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate18.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs0 = errors;let valid0 = false;let passing0 = null;const _errs1 = errors;const _errs3 = errors;let valid2 = false;let passing1 = null;const _errs4 = errors;const _errs6 = errors;let valid4 = false;let passing2 = null;const _errs7 = errors;if(!(validate19(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate19.errors : vErrors.concat(validate19.errors);errors = vErrors.length;}else {var props0 = validate19.evaluated.props;}var _valid2 = _errs7 === errors;if(_valid2){valid4 = true;passing2 = 0;}const _errs8 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.image === undefined){const err0 = {instancePath,schemaPath:"#/definitions/imageContainer/required",keyword:"required",params:{missingProperty: "image"},message:"must have required property '"+"image"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.image !== undefined){if(typeof data.image !== "string"){const err1 = {instancePath:instancePath+"/image",schemaPath:"#/definitions/imageContainer/properties/image/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}else {const err2 = {instancePath,schemaPath:"#/definitions/imageContainer/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}var _valid2 = _errs8 === errors;if(_valid2 && valid4){valid4 = false;passing2 = [passing2, 1];}else {if(_valid2){valid4 = true;passing2 = 1;if(props0 !== true){props0 = props0 || {};props0.image = true;}}}if(!valid4){const err3 = {instancePath,schemaPath:"#/oneOf/0/allOf/0/oneOf/0/allOf/0/oneOf",keyword:"oneOf",params:{passingSchemas: passing2},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}else {errors = _errs6;if(vErrors !== null){if(_errs6){vErrors.length = _errs6;}else {vErrors = null;}}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.appPort !== undefined){let data1 = data.appPort;if(((!((typeof data1 == "number") && (!(data1 % 1) && !isNaN(data1)))) && (typeof data1 !== "string")) && (!(Array.isArray(data1)))){const err4 = {instancePath:instancePath+"/appPort",schemaPath:"#/definitions/nonComposeBase/properties/appPort/type",keyword:"type",params:{type: schema26.properties.appPort.type},message:"must be integer,string,array"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(Array.isArray(data1)){const len0 = data1.length;for(let i0=0; i0 { + expect(validateBase({ image: "mcr.microsoft.com/devcontainers/base:ubuntu" })).toBe(true); + expect(validateFeature({ id: "my-feature", version: "1.0.0" })).toBe(true); +}); + +test("provenance() has one entry per vendored schema, each with a url, commit and date", () => { + const schemaFiles = readdirSync(schemasDir).filter((f) => f.endsWith(".schema.json")); + const entries = provenance(); + + expect(Object.keys(entries).sort()).toEqual(schemaFiles.sort()); + for (const entry of Object.values(entries)) { + expect(entry.url).toMatch(/^https:\/\//); + expect(entry.commit).toMatch(/^[0-9a-f]{40}$/); + expect(entry.retrieved).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } +}); + +// the schemas come straight from the pinned submodule, so there's no file to diff; the +// pin is what drifts. A clone without `--recurse-submodules` leaves devcontainer-spec/ +// empty (no `.git`), so this skips instead of failing. +test.skipIf(!existsSync(path.join(submoduleDir, ".git")))( + "the submodule's pinned commit matches provenance.json's commit field for every schema", + async () => { + const proc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd: submoduleDir, stdout: "pipe" }); + const pinnedCommit = (await new Response(proc.stdout).text()).trim(); + + for (const entry of Object.values(provenance())) { + expect(entry.commit).toBe(pinnedCommit); + } + }, +); + +test("offline determinism; same input, same output, no network", () => { + // the generated validators are pure sync functions (no fetch anywhere), so the same + // input twice has to give identical results, valid or not + const valid = { image: "mcr.microsoft.com/devcontainers/base:ubuntu" }; + const invalid = { image: "mcr.microsoft.com/devcontainers/base:ubuntu", notAKnownProperty: true }; + + expect(validateBase(structuredClone(valid))).toBe(validateBase(structuredClone(valid))); + + const firstRun = validateBase(structuredClone(invalid)); + const firstErrors = structuredClone(validateBase.errors); + const secondRun = validateBase(structuredClone(invalid)); + const secondErrors = validateBase.errors; + + expect(secondRun).toBe(firstRun); + expect(secondErrors).toEqual(firstErrors); +}); + +test("unevaluatedProperties is genuinely enforced by the generated validator", () => { + // catches the wrong Ajv entrypoint (ajv/dist/2019 vs the draft-07 default); the wrong + // one quietly accepts this fixture + const valid = validateBase({ + image: "mcr.microsoft.com/devcontainers/base:ubuntu", + notAKnownProperty: true, + }); + + expect(valid).toBe(false); + expect(validateBase.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ keyword: "unevaluatedProperties" })]), + ); +}); diff --git a/src/schema/schemas.ts b/src/schema/schemas.ts new file mode 100644 index 0000000..0b46f4e --- /dev/null +++ b/src/schema/schemas.ts @@ -0,0 +1,24 @@ +import { validate } from "./generated/devContainer.base.validator"; +import schemas from "./provenance.json" with { type: "json" }; + +/** + * Where a vendored schema came from: upstream URL, the `devcontainer-spec` submodule + * commit, and the date it was retrieved. + * + * `schemas/` is a symlink into the pinned submodule, not a copy; so this data (not a + * file diff) is what a drift check compares against upstream. + */ +export interface Provenance { + readonly url: string; + readonly commit: string; + readonly retrieved: string; +} + +/** One entry per vendored schema, keyed by filename. */ +export function provenance(): Record { + return { + "devContainer.base.schema.json": schemas["devContainer.base.schema.json"], + "devContainer.schema.json": schemas["devContainer.schema.json"], + "devContainerFeature.schema.json": schemas["devContainerFeature.schema.json"], + }; +} diff --git a/src/vfs/bunfs.ts b/src/vfs/bunfs.ts new file mode 100644 index 0000000..2a69289 --- /dev/null +++ b/src/vfs/bunfs.ts @@ -0,0 +1,70 @@ +import type { DirEntry, FileSystem, FileType, Stats } from "./vfs"; +import { byName } from "./vfs"; +import { join } from "node:path"; +import { readdir } from "node:fs/promises"; + +/** + * The on-disk {@link FileSystem} the CLI uses. Bun-only APIs (`Bun.file` etc.) stay in + * `src/vfs`, `src/cli` and `src/position`; the published package also has to run on + * Node 22+, so everything else stays runtime-agnostic. + */ +export class BunFS implements FileSystem { + async readFile(path: string): Promise { + return Bun.file(path).text(); + } + + async stat(path: string): Promise { + try { + return { kind: getFileType(await Bun.file(path).stat()) }; + } catch (err) { + if (isMissing(err)) { + return undefined; + } + throw err; + } + } + + async readDir(path: string): Promise { + const entries = await Promise.all( + (await readdir(path, { withFileTypes: true })).map(async (entry) => ({ + name: entry.name, + // Only a symlink costs a second syscall: a Dirent already knows every other kind. + kind: entry.isSymbolicLink() + ? await targetKind(join(path, entry.name)) + : getFileType(entry), + })), + ); + + return entries.sort(byName); + } +} + +/** + * Distinguishes absence, which {@link FileSystem.stat} reports as `undefined`, + * from a real I/O failure such as `EACCES` or `ELOOP`, which it rejects with. + * `ENOTDIR` counts as absence: nothing is at a path below a file. + */ +function isMissing(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +/** + * Resolves a symlink's target kind for a directory listing. A dangling or + * looping symlink is `other` rather than an error: the entry itself exists, so + * failing the whole listing would be indistinguishable from the directory being + * missing. This also absorbs the race where an entry is deleted between the + * `readdir` and its `stat`. + */ +async function targetKind(path: string): Promise { + try { + return getFileType(await Bun.file(path).stat()); + } catch { + return "other"; + } +} + +/** Accepts both a `Stats` and a `Dirent`, which agree on these two predicates. */ +function getFileType(entry: { isFile(): boolean; isDirectory(): boolean }): FileType { + return entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "other"; +} diff --git a/src/vfs/overlayfs.ts b/src/vfs/overlayfs.ts new file mode 100644 index 0000000..08a2db7 --- /dev/null +++ b/src/vfs/overlayfs.ts @@ -0,0 +1,72 @@ +import type { DirEntry, FileSystem, Stats } from "./vfs"; +import { byName } from "./vfs"; +import { resolve, parse } from "node:path"; + +/** + * A {@link FileSystem} that serves in-memory buffers in place of the files + * beneath them. The LSP keeps one buffer per open document, + * updated from `textDocument/didChange`, so analysis sees what the user is typing + * rather than what was last saved. + * + * Only files are overlaid. Directories always come from the base, so a buffer + * whose parent directory does not exist there is unsupported, and the base wins + * when a buffer's name collides with a directory. + * + * Buffers are the only thing held in memory: reads that fall through are never + * cached, so a file edited outside the editor is picked up on the next read. + */ +export class OverlayFS implements FileSystem { + private readonly base: FileSystem; + private readonly buffers: Map; + + /** @param base Serves every path that has no buffer, usually a `BunFS`. */ + constructor(base: FileSystem) { + this.base = base; + this.buffers = new Map(); + } + + /** + * Sets the buffer for `path`, shadowing whatever the base holds there. Any + * spelling of the same absolute path (`a/../b`, `./b`) refers to one buffer. + */ + set(path: string, text: string): void { + this.buffers.set(resolve(path), text); + } + + /** Drops the buffer for `path`, so reads fall through to the base again. */ + delete(path: string): void { + this.buffers.delete(resolve(path)); + } + + async readFile(path: string): Promise { + // `??`, not `||`: an empty buffer is a buffer, not a miss. + return this.buffers.get(resolve(path)) ?? this.base.readFile(path); + } + + async stat(path: string): Promise { + const canonPath = resolve(path); + return ( + (await this.base.stat(canonPath)) ?? + (this.buffers.has(canonPath) ? { kind: "file" } : undefined) + ); + } + + async readDir(path: string): Promise { + const canonPath = resolve(path); + const entries = new Map(); + + // Base first, so it wins a name collision the way stat() does. + for (const entry of await this.base.readDir(canonPath)) { + entries.set(entry.name, entry); + } + + for (const bufferPath of this.buffers.keys()) { + const { dir, base } = parse(bufferPath); + if (dir === canonPath && !entries.has(base)) { + entries.set(base, { kind: "file", name: base }); + } + } + + return [...entries.values()].sort(byName); + } +} diff --git a/src/vfs/vfs.test.ts b/src/vfs/vfs.test.ts new file mode 100644 index 0000000..dd2c08c --- /dev/null +++ b/src/vfs/vfs.test.ts @@ -0,0 +1,187 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunFS } from "./bunfs"; +import { OverlayFS } from "./overlayfs"; +import type { FileSystem } from "./vfs"; + +// /hello.txt, /B.txt, /_under.txt, /sub/nested.txt, +// /link -> sub, /dangling -> (nothing), /locked/ (mode 000). +// The mixed-case names pin codepoint ordering; the dangling symlink and the +// unreadable directory pin the difference between absence and a real failure. +let root: string; +// chmod cannot lock a directory against root, so the EACCES cases cannot run there. +// Windows has no getuid and its chmod doesn't enforce POSIX-style access at all — +// 0o000 only toggles the read-only attribute, so the directory stays readable. +const canDenyAccess = process.platform !== "win32" && process.getuid?.() !== 0; +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "dcx-vfs-")); + mkdirSync(join(root, "sub")); + writeFileSync(join(root, "hello.txt"), "café ☕\n"); + writeFileSync(join(root, "B.txt"), "B\n"); + writeFileSync(join(root, "_under.txt"), "_\n"); + writeFileSync(join(root, "sub", "nested.txt"), "nested\n"); + symlinkSync(join(root, "sub"), join(root, "link")); + symlinkSync(join(root, "nowhere"), join(root, "dangling")); + mkdirSync(join(root, "locked")); + writeFileSync(join(root, "locked", "secret.txt"), "secret\n"); + chmodSync(join(root, "locked"), 0o000); +}); +afterAll(() => { + chmodSync(join(root, "locked"), 0o755); + rmSync(root, { recursive: true, force: true }); +}); + +// The FileSystem contract. An OverlayFS with no buffers must be indistinguishable from its base. +describe.each<[string, () => FileSystem]>([ + ["BunFS", () => new BunFS()], + ["OverlayFS with no buffers", () => new OverlayFS(new BunFS())], +])("%s", (_, create) => { + const fs = create(); + + test("readFile decodes UTF-8", async () => { + expect(await fs.readFile(join(root, "hello.txt"))).toBe("café ☕\n"); + }); + + test("readFile rejects for a missing file", async () => { + await expect(fs.readFile(join(root, "missing"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test("stat reports files and directories, following symlinks", async () => { + expect((await fs.stat(join(root, "hello.txt")))?.kind).toBe("file"); + expect((await fs.stat(join(root, "sub")))?.kind).toBe("directory"); + expect((await fs.stat(join(root, "link")))?.kind).toBe("directory"); + }); + + test("stat resolves undefined for a missing path, even beneath a file", async () => { + expect(await fs.stat(join(root, "missing"))).toBeUndefined(); + expect(await fs.stat(join(root, "hello.txt", "child"))).toBeUndefined(); + expect(await fs.stat(join(root, "dangling"))).toBeUndefined(); + }); + + test.if(canDenyAccess)("stat rejects when the path exists but cannot be read", async () => { + // Absence is a result; a permission failure is not, or fs/* rules report + // "does not exist" for a path that does. + await expect(fs.stat(join(root, "locked", "secret.txt"))).rejects.toMatchObject({ + code: "EACCES", + }); + }); + + test("readDir lists entries in codepoint order, following symlinks", async () => { + expect(await fs.readDir(root)).toEqual([ + { name: "B.txt", kind: "file" }, + { name: "_under.txt", kind: "file" }, + { name: "dangling", kind: "other" }, + { name: "hello.txt", kind: "file" }, + { name: "link", kind: "directory" }, + { name: "locked", kind: "directory" }, + { name: "sub", kind: "directory" }, + ]); + }); + + test("readDir rejects for a missing directory", async () => { + await expect(fs.readDir(join(root, "missing"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test.if(canDenyAccess)("readDir rejects for an unreadable directory", async () => { + await expect(fs.readDir(join(root, "locked"))).rejects.toMatchObject({ code: "EACCES" }); + }); +}); + +describe("OverlayFS", () => { + let fs: OverlayFS; + beforeEach(() => { + fs = new OverlayFS(new BunFS()); + }); + + test("a buffer shadows the file on disk", async () => { + fs.set(join(root, "hello.txt"), "unsaved"); + expect(await fs.readFile(join(root, "hello.txt"))).toBe("unsaved"); + }); + + test("a buffer with no file on disk reads and stats as a file", async () => { + const path = join(root, "sub", "new.json"); + fs.set(path, "{}"); + expect(await fs.readFile(path)).toBe("{}"); + expect((await fs.stat(path))?.kind).toBe("file"); + }); + + test("an empty buffer shadows the file on disk", async () => { + // "" is falsy: a truthiness check here serves the stale disk contents instead. + fs.set(join(root, "hello.txt"), ""); + expect(await fs.readFile(join(root, "hello.txt"))).toBe(""); + }); + + test("an empty buffer with no file on disk reads as empty", async () => { + const path = join(root, "sub", "blank.json"); + fs.set(path, ""); + expect(await fs.readFile(path)).toBe(""); + expect((await fs.stat(path))?.kind).toBe("file"); + }); + + test("readDir merges buffers into their own directory only, once each", async () => { + fs.set(join(root, "sub", "new.json"), "{}"); + fs.set(join(root, "sub", "nested.txt"), "changed"); + expect(await fs.readDir(join(root, "sub"))).toEqual([ + { name: "nested.txt", kind: "file" }, + { name: "new.json", kind: "file" }, + ]); + expect((await fs.readDir(root)).map((entry) => entry.name)).toEqual([ + "B.txt", + "_under.txt", + "dangling", + "hello.txt", + "link", + "locked", + "sub", + ]); + }); + + test("a buffer never shadows or duplicates a directory in readDir", async () => { + // Only files are overlaid, so the base wins the name and stat() agrees. + fs.set(join(root, "sub"), "not a directory"); + const entries = (await fs.readDir(root)).filter((entry) => entry.name === "sub"); + expect(entries).toEqual([{ name: "sub", kind: "directory" }]); + expect((await fs.stat(join(root, "sub")))?.kind).toBe("directory"); + }); + + test("delete falls back to the file on disk", async () => { + const path = join(root, "hello.txt"); + fs.set(path, "unsaved"); + fs.delete(path); + expect(await fs.readFile(path)).toBe("café ☕\n"); + }); + + test("a buffer is found under any spelling of its path", async () => { + // Template string, not join(): join() would normalise the `..` before OverlayFS saw it. + fs.set(`${root}/sub/../hello.txt`, "unsaved"); + expect(await fs.readFile(join(root, "hello.txt"))).toBe("unsaved"); + }); + + // Reads must not be cached as buffers: the LSP outlives any number of + // external writes (a git checkout, a formatter, a sibling process). + describe("a read is not a buffer", () => { + let mutRoot: string; + let path: string; + beforeEach(() => { + mutRoot = mkdtempSync(join(tmpdir(), "dcx-vfs-mut-")); + path = join(mutRoot, "changing.txt"); + writeFileSync(path, "first\n"); + }); + afterEach(() => rmSync(mutRoot, { recursive: true, force: true })); + + test("a file rewritten on disk reads as its new contents", async () => { + expect(await fs.readFile(path)).toBe("first\n"); + writeFileSync(path, "second\n"); + expect(await fs.readFile(path)).toBe("second\n"); + }); + + test("a file removed from disk after a read stats as absent", async () => { + await fs.readFile(path); + rmSync(path); + expect(await fs.stat(path)).toBeUndefined(); + expect(await fs.readDir(mutRoot)).toEqual([]); + }); + }); +}); diff --git a/src/vfs/vfs.ts b/src/vfs/vfs.ts new file mode 100644 index 0000000..5f58074 --- /dev/null +++ b/src/vfs/vfs.ts @@ -0,0 +1,54 @@ +/** What a path points at, after following symlinks. */ +export interface Stats { + /** `other` covers sockets, FIFOs, and devices. */ + readonly kind: FileType; +} + +/** One child of a directory, as listed by {@link FileSystem.readDir}. */ +export interface DirEntry extends Stats { + /** The entry's own name, not its full path. */ + readonly name: string; +} + +/** + * The only way core code touches the filesystem. + * An editor holds unsaved buffers that do not exist on disk, so rules and + * discovery read through this interface rather than `Bun.file` or `node:fs`, + * and the LSP swaps in an `OverlayFS`. + * + * All paths are absolute. + */ +export interface FileSystem { + /** + * Reads a file as UTF-8 text. Rejects with `code: "ENOENT"` if nothing is there, + * or another error if it is not a file. + */ + readFile(path: string): Promise; + + /** + * Describes the entry at `path`, following symlinks. + * + * Resolves `undefined` when nothing is there, including when a parent is a file + * (ENOTDIR): the `fs/*` rules report missing paths, so absence is a result, not + * an error. Any other I/O failure rejects. + */ + stat(path: string): Promise; + + /** + * Lists a directory's children sorted by name, following symlinks to fill in + * `kind`. Rejects with `code: "ENOENT"` if nothing is there, or another error if + * it is not a directory. + */ + readDir(path: string): Promise; +} + +export type FileType = "file" | "directory" | "other"; + +/** + * Orders directory entries by codepoint, so a listing does not depend on the + * host locale or ICU build. `localeCompare` would order `_x a.md a.txt B.txt` + * under en-US and `B.txt Z.txt _x a.md` under LC_ALL=C, which would leak into + * diagnostic ordering in CLI output. + */ +export const byName = (a: DirEntry, b: DirEntry): number => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0; diff --git a/testdata/.gitkeep b/testdata/.gitkeep deleted file mode 100644 index 74b75d0..0000000 --- a/testdata/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Fixture corpus and golden files land here (DCL-11, DCL-20). diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}