From d5fd184fcbbbf4c347bb92ba47e3422b0ff850b0 Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Thu, 10 Sep 2026 21:31:14 -0600 Subject: [PATCH 1/9] test convert to bun/ts --- .gitignore | 48 +- .golangci.yml | 36 - Makefile | 85 - README.md | 108 +- cmd/dcx/main.go | 76 - docs/DESIGN.md | 886 +++++++---- extensions/vscode/.gitkeep | 1 - go.mod | 3 - pkg/diagnostic/doc.go | 2 - pkg/discovery/doc.go | 2 - pkg/doc.go | 21 - pkg/features/doc.go | 2 - pkg/jsonc/doc.go | 2 - pkg/lint/doc.go | 2 - pkg/lintconfig/doc.go | 2 - pkg/model/doc.go | 2 - pkg/position/bench_test.go | 43 - pkg/position/doc.go | 2 - pkg/position/fuzz_test.go | 73 - pkg/position/oracle_test.go | 106 -- pkg/position/position.go | 214 --- pkg/position/position_test.go | 405 ----- pkg/position/testdata/README.md | 12 - pkg/position/testdata/large-commented.jsonc | 1558 ------------------- pkg/registry/doc.go | 2 - pkg/report/doc.go | 2 - pkg/rules/doc.go | 3 - pkg/rules/engine.go | 1 - pkg/rules/rule.go | 1 - pkg/schema/doc.go | 2 - pkg/suppress/doc.go | 2 - pkg/vfs/doc.go | 2 - schemas/.gitkeep | 1 - testdata/.gitkeep | 1 - 34 files changed, 652 insertions(+), 3056 deletions(-) delete mode 100644 .golangci.yml delete mode 100644 Makefile delete mode 100644 cmd/dcx/main.go delete mode 100644 extensions/vscode/.gitkeep delete mode 100644 go.mod delete mode 100644 pkg/diagnostic/doc.go delete mode 100644 pkg/discovery/doc.go delete mode 100644 pkg/doc.go delete mode 100644 pkg/features/doc.go delete mode 100644 pkg/jsonc/doc.go delete mode 100644 pkg/lint/doc.go delete mode 100644 pkg/lintconfig/doc.go delete mode 100644 pkg/model/doc.go delete mode 100644 pkg/position/bench_test.go delete mode 100644 pkg/position/doc.go delete mode 100644 pkg/position/fuzz_test.go delete mode 100644 pkg/position/oracle_test.go delete mode 100644 pkg/position/position.go delete mode 100644 pkg/position/position_test.go delete mode 100644 pkg/position/testdata/README.md delete mode 100644 pkg/position/testdata/large-commented.jsonc delete mode 100644 pkg/registry/doc.go delete mode 100644 pkg/report/doc.go delete mode 100644 pkg/rules/doc.go delete mode 100644 pkg/rules/engine.go delete mode 100644 pkg/rules/rule.go delete mode 100644 pkg/schema/doc.go delete mode 100644 pkg/suppress/doc.go delete mode 100644 pkg/vfs/doc.go delete mode 100644 schemas/.gitkeep delete mode 100644 testdata/.gitkeep 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/.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/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/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/docs/DESIGN.md b/docs/DESIGN.md index 3337d24..69b5aa2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,33 +1,34 @@ # dcx — Design Document **Status:** Draft for review -**Date:** 2026-09-04 -**Language:** Go (see [Language Decision](#2-language-decision)) +**Date:** 2026-09-10 +**Language:** TypeScript on Bun (see [Language Decision](#2-language-decision)) --- ## 1. Overview -`dcx` is a standalone, dependency-free static analyser for `devcontainer.json`, -built against the +`dcx` is a standalone 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 | +| **Core library** (`src/…`) | Reusable TypeScript modules: parse → model → analyze → diagnose | This doc | +| **CLI** (`src/cli`) | `dcx check` — for terminals, CI, pre-commit | This doc | +| **LSP server** (`src/server`) | `dcx serve` — the same package, over stdio | Designed for, built later | +| **VSCode extension** (`extensions/vscode`) | Imports the server in-process | 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. +- **G2** — Ship as a single npm package with no runtime dependency beyond Bun or + Node, and no native addons. Self-contained executables are available for + environments without a JavaScript runtime. - **G3** — Be architecturally ready for an LSP from day one: no global state, no - direct filesystem access from rules, cancellable analysis, byte-accurate + direct filesystem access from rules, cancellable analysis, editor-accurate positions, and machine-applicable fixes. - **G4** — Be usable non-interactively: stable exit codes, JSON and SARIF output, GitHub annotations, pre-commit hook. @@ -66,41 +67,76 @@ human can act on. ## 2. Language Decision -**Chosen: Go.** +**Chosen: TypeScript, running on Bun.** ### 2.1 Rationale -| Criterion | Go | TypeScript | Python | +| Criterion | TypeScript / Bun | Go | 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. +| CLI distribution | npm package; optional 78 MB executable | Single ~2 MB static binary | Needs Python/uv | +| Cold start | **9 ms measured** (3 ms of which is process spawn) | ~5 ms | ~200–400 ms | +| JSON Schema 2019-09 + `unevaluatedProperties` | Ajv 2019, precompiled standalone | `santhosh-tekuri/jsonschema/v6` | `jsonschema` 4.x | +| JSONC parser with positions | **`jsonc-parser` — the parser VS Code itself uses** | Hand-rolled (~700 LOC) | Hand-rolled | +| LSP framework | `vscode-languageserver-node` — the reference implementation | `tliron/glsp`, `go.lsp.dev` | `pygls` | +| Extension integration | **In-process import; no subprocess, no binary to ship** | Spawn a per-platform binary | N/A | + +The measurements above were taken on this machine against a trivial CLI: `bun +build --compile --minify --bytecode` produces a 78 MB executable that starts in 9 ms +median over 30 runs, against a 3 ms floor for `/bin/true`. + +Three arguments decide it. + +**The cold-start objection was aimed at Node, not at Bun.** A ~5 ms versus ~9 ms +difference, half of which is process-spawn overhead that any language pays, is not +something a human operating a linter can perceive. Startup latency is no longer a +differentiator; it was the load-bearing argument for a compiled language and it does +not survive measurement. + +**`jsonc-parser` is not merely a convenient library — it is the parser VS Code +uses.** A linter for a JSONC file whose primary consumer is VS Code has exactly one +thing it must never get wrong: disagreeing with the editor about what the document +says. Adopting the editor's own parser makes that agreement structural rather than +aspirational. Every hand-rolled parser is a standing invitation to diverge on some +escape sequence or recovery decision, and that divergence surfaces as a false +positive in the one place it is least welcome. + +**The extension stops being a client at all.** The argument for Go was that the +extension is a thin client either way, so sharing a language buys little. That holds +only while the server is a separate process. In TypeScript the server is an +`import`: no binary to resolve, no subprocess to spawn or supervise, no +platform-specific VSIX matrix, no version skew between an extension and a binary +released on a different cadence. §10.3 of the Go design specified six build targets +and a CI matrix to copy the right executable into `bin/` before packaging. That +entire section is deleted here rather than ported. ### 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`. +- **78 MB executables.** Bun embeds a full JavaScript engine, and its own + documentation concedes the binary is too big. This is a real and permanent loss + against Go's ~2 MB. It is mitigated, not solved, by making npm the primary + distribution channel (§12): the audience for a `devcontainer.json` linter runs + Node already, and the extension ships no binary at all. Executables remain + available for Docker images and runtime-free CI. +- **No native fuzzer.** Go's `testing.F` has no Bun equivalent. Property-based + testing via `fast-check` covers the same ground with more setup (§11). +- **Ajv generates validator code at runtime.** That fits poorly with + `--compile --bytecode`. We compile validators to standalone modules at build time + instead (§5.5), which is better practice regardless — it makes schema compilation + a build-time cost rather than a per-invocation one. +- **Single-threaded analysis.** Rules run sequentially rather than in goroutines. + For a ~24 KB document this is not a real cost, and it removes a class of data race + the Go design had to reason about (§5.6). +- **A dependency tree.** Go's ethos of near-zero dependencies is not available here. + We hold the line at **three** runtime dependencies in the core — `jsonc-parser`, + `ajv`, and `yaml` — each pinned in the lockfile, audited, and justified in §5. The + LSP server entry point adds `vscode-languageserver` as an optional fourth (§9). --- ## 3. Specification Model -Facts extracted from the spec that drive the design. +Facts extracted from the spec that drive the design. This section is +language-independent and is unchanged from the original design. ### 3.1 Discovery order @@ -186,21 +222,21 @@ HTTPS tarball, and local relative path (`./feature`). Feature metadata lives in ``` ┌────────────────────────────────────────┐ CLI ──────────────┤ │ - LSP server ───────┤ pkg/lint (facade) │ - Extension ────────┤ Analyze(ctx, Document, Options) │ + LSP server ───────┤ src/lint (facade) │ + Extension ────────┤ analyze(doc, options, signal) │ └────────────────────┬───────────────────┘ │ ┌──────────────┬──────────────┬────────┴──────┬──────────────┬─────────────┐ │ discovery │ jsonc │ model │ rules │ report │ - │ locate the │ lex→parse→ │ CST → typed │ engine + │ text/json/ │ - │ config file │ CST (+pos) │ semantic AST │ registry │ sarif/gh │ + │ locate the │ jsonc-parser│ CST → typed │ engine + │ text/json/ │ + │ config file │ adapter→CST │ 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 │ │ │ │ │ + │ overlay FS │ │ LineIndex │ │ text-import │ │ OCI + cache│ │ Range/Fix │ + │ (unsaved │ │ UTF-16↔ │ │ + standalone│ │ (network) │ │ Severity │ + │ buffers) │ │ display │ │ Ajv 2019 │ │ │ │ │ └─────────────┘ └───────────┘ └─────────────┘ └────────────┘ └───────────┘ ``` @@ -209,186 +245,342 @@ HTTPS tarball, and local relative path (`./feature`). Feature metadata lives in 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. +1. **No `process.exit`, no thrown exception escaping the core, no `console.*` below + `src/cli`.** The core returns values. Only the CLI entry point decides process + fate. An exception that escapes `analyze()` takes down a language server that is + expected to stay up for hours. +2. **All filesystem access goes through the `FileSystem` interface.** 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 `Bun.file` directly + is unusable in an editor. +3. **Every diagnostic carries a `Range` of UTF-16 code-unit offsets.** This is the + unit JavaScript strings are indexed in, the unit `jsonc-parser` reports, and the + unit the LSP `Position` type is defined in — so the common path requires no + conversion at all. Rendering a terminal caret needs a *display width*, not a byte + count, and that conversion is `src/position`'s job (§5.2). +4. **`analyze()` accepts an `AbortSignal` and honours it.** 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. + +Invariant 3 is the one that changed direction from the original design, which +mandated byte offsets internally. In Go that was correct: strings are byte slices and +UTF-16 is foreign, so byte offsets are the natural internal unit and the LSP edge +pays the conversion. In JavaScript the same reasoning points the opposite way. +Holding byte offsets internally here would mean a `TextEncoder` round-trip at every +boundary — on entry from the parser, and again on exit to the editor — to arrive back +at the unit we started in. ### 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 +├── package.json # subpath exports; bin: dcx +├── tsconfig.json +├── src/ +│ ├── cli/ # argv parsing, process exit, stdout — check, explain, feature +│ ├── server/ # LSP server over stdio +│ ├── lint/ # facade: analyze(), Document, Options +│ ├── vfs/ # FileSystem interface, Bun impl, overlay impl +│ ├── position/ # Offset, Range, LineIndex, display-width conversion │ ├── diagnostic/ # Diagnostic, Severity, Fix, TextEdit -│ ├── jsonc/ # lexer, parser, CST nodes, error recovery +│ ├── jsonc/ # jsonc-parser adapter → CST + comment list │ ├── discovery/ # config file location per spec §3.1 -│ ├── schema/ # go:embed'd upstream schemas + validator +│ ├── schema/ # vendored schemas + generated Ajv validators │ ├── model/ # typed semantic model over the CST │ ├── features/ # feature ref parsing, OCI resolution, cache -│ ├── registry/ # extension-registry adapters (Open VSX, gallery, static) +│ ├── registry/ # extension-registry adapters (Open VSX, gallery, policy) │ ├── 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 +│ │ ├── engine.ts # registry, ordering, execution +│ │ ├── rule.ts # Rule interface +│ │ └── / # one directory per rule category │ └── report/ # text, json, sarif, github formatters ├── schemas/ # vendored upstream JSON schemas +├── scripts/ # build-time codegen (Ajv standalone compilation) ├── testdata/ # fixture corpus + golden files -└── extensions/vscode/ # TypeScript extension +└── extensions/vscode/ # VSCode 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. +The public API surface is declared explicitly through `exports` in `package.json` +rather than by directory convention: + +```json +{ + "exports": { + ".": "./src/lint/index.ts", + "./diagnostic": "./src/diagnostic/index.ts", + "./rules": "./src/rules/index.ts", + "./server": "./src/server/index.ts" + } +} +``` + +Anything not listed is private and may change without a major version. This is +stricter than Go's `pkg/` convention, which exports every capitalised identifier in +every package whether or not that was intended. + +Bun runs TypeScript sources directly, so there is no build step during development +and no `dist/` to keep in sync. The only generated artefacts are the standalone Ajv +validators (§5.5), produced by `scripts/` and committed. --- ## 5. Component Specifications -### 5.1 `pkg/jsonc` — the parser +### 5.1 `src/jsonc` — the parser adapter + +The original design called for a hand-written lexer and recursive-descent parser, +roughly 700 lines, justified by the absence of a Go library that preserves comments +*and* positions *and* recovers from errors. TypeScript has exactly that library, and +it is the one VS Code uses. `src/jsonc` is therefore an **adapter**, not a parser — +roughly 150 lines. -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. +`jsonc-parser` supplies, directly: -**Node kinds:** `Object`, `Array`, `Property`, `String`, `Number`, `Boolean`, `Null`, -`Comment`, `Error`. +| Requirement | Mechanism | +| --- | --- | +| CST with positions | `parseTree()` → nodes with `offset`, `length`, `type`, `colonOffset` | +| Error recovery | Parsing continues past faults; `ParseError[]` is an out-parameter | +| Duplicate keys preserved | Property children are a source-ordered list, not a map | +| Trailing commas | `allowTrailingComma` option; positions recovered via `visit()` | +| Node lookup by JSON Pointer | `findNodeAtLocation(root, path)` | +| Node lookup by offset | `findNodeAtOffset(root, offset)` — the LSP hover/completion primitive | +| Format-preserving edits | `modify()` / `applyEdits()` | + +Two things it does not give us, which the adapter supplies: + +**Comments are not tree nodes.** `parseTree()` discards them; `visit()` reports them +through an `onComment(offset, length, startLine, startChar)` callback. The adapter +runs `visit()` alongside `parseTree()` and collects comments into a source-ordered +side list, then attaches each to the property that follows it. Suppression directives +(§8.2) read from that list. This is a genuine ergonomic loss against a CST with +`Comment` nodes in it, and it is the main cost of the decision — but attaching by +offset is about twenty lines, and it buys the parser itself for free. + +**Trailing commas are permitted, not reported.** With `allowTrailingComma: true` the +parse succeeds silently; with it `false` the position arrives as a `ParseError`. The +adapter parses permissively and locates trailing commas through `visit()`'s +`onSeparator` callback, recording them on the enclosing node so +`syntax/trailing-comma` can report a span. + +The resulting `Document` is: + +```ts +interface Document { + readonly uri: string; + readonly text: string; + readonly root: Node | undefined; // undefined for empty/unparseable input + readonly comments: readonly Comment[]; + readonly errors: readonly ParseError[]; + readonly lines: LineIndex; +} +``` -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. +Rules consume `Document` and the re-exported `Node` type. Should `jsonc-parser` ever +need replacing, the blast radius is this directory. + +**`modify()` and `applyEdits()` deserve particular note.** They perform +format-preserving edits against the *source text*, honouring the surrounding +indentation and leaving comments intact. In the Go design, every fixable rule had to +construct its own `TextEdit` spans by hand and the convergence tests existed largely +to catch mistakes in that arithmetic. Here, a rule that wants to move root +`extensions` into `customizations.vscode.extensions` expresses it as two `modify()` +calls against JSON paths. §13's M10 shrinks accordingly. + +### 5.2 `src/position` — coordinates + +Internally everything is a **UTF-16 code-unit offset**: the unit JavaScript string +indices use, the unit `jsonc-parser` emits, and the unit LSP `Position` is defined +in. Conversion to an LSP position is therefore a line lookup and a subtraction, with +no character re-encoding on the path an editor exercises on every keystroke. + +`LineIndex` is built once per document — a single pass recording the offset of each +line start — and converts: + +- offset → `{ line, character }` for LSP, by binary search over line starts +- offset → `{ line, column }` for terminal output, where *column* is a **display + width**, not a code-unit count + +The second conversion is the one that carries real complexity, and it is complexity +the original design did not account for. A caret rendered under a span must line up +with what the terminal actually draws, which means accounting for East Asian wide +characters (two columns), combining marks (zero), and tabs (to the next tab stop). A +byte count gets this wrong for exactly the same inputs a code-unit count does; the +Go design's "byte offset ↔ (line, UTF-8 column)" mapping would not have produced a +correctly aligned caret for a CJK container name either. We use +`Bun.stringWidth()`, which implements the width rules natively and requires no +dependency. + +Surrogate pairs are the remaining subtlety. An emoji in a `name` field is one code +point, two UTF-16 code units, and two display columns. Ranges must never split a +surrogate pair; the adapter asserts this in development builds. + +### 5.3 `src/vfs` — filesystem abstraction + +```ts +interface FileSystem { + readFile(path: string): Promise; + stat(path: string): Promise; + readDir(path: string): Promise; +} +``` -**Requirements:** +Two implementations: `BunFS` (the CLI, over `Bun.file`) and `OverlayFS` (the LSP — +in-memory documents layered over `BunFS`). Rules receive a `FileSystem` and never +import `Bun.file` or `node:fs` directly. -- 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. +`stat` returns `undefined` rather than throwing on a missing path. The `fs/*` rules +(§6.5) exist precisely to report missing paths, so absence is an expected result, not +an exceptional one — and invariant 1 says exceptions do not escape the core. -### 5.2 `pkg/position` — coordinates +### 5.4 `src/model` — semantic model -Internally everything is a byte offset. `LineIndex` is built once per document and -converts: +Lowers the CST into a typed structure, and — critically — **discriminates the +scenario** before schema validation runs: -- byte offset ↔ (line, UTF-8 column) — for terminal output -- byte offset ↔ (line, UTF-16 code unit) — for LSP `Position` +```ts +type Scenario = + | { kind: "unknown" } // no container source found + | { kind: "image"; image: Field } + | { kind: "dockerfile"; dockerfile: Field; legacy: boolean } + | { kind: "compose"; files: Field[]; service: Field | undefined } + | { kind: "ambiguous"; sources: Field[] } // more than one of the above + | { kind: "metadataOnly" }; // valid: common properties only +``` -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. +A discriminated union rather than Go's `iota` enum, and the difference is not +cosmetic. The Go version carried a `Scenario` integer and left every consumer to +re-derive which fields were populated; here the payload travels with the tag, and a +`switch` over `kind` that forgets a case is a compile error under `strict`. The +`ambiguous` case carrying its conflicting sources is what lets +`scenario/conflicting-source` name both offenders with spans rather than reporting a +generic conflict. -### 5.3 `pkg/vfs` — filesystem abstraction +Every field on the model retains a back-pointer to its CST node: -```go -type FS interface { - ReadFile(path string) ([]byte, error) - Stat(path string) (fs.FileInfo, error) - ReadDir(path string) ([]fs.DirEntry, error) +```ts +interface Field { + readonly value: T; + readonly node: Node; // for the span } ``` -Two implementations: `OSFS` (the CLI) and `OverlayFS` (the LSP — in-memory documents -layered over `OSFS`). Rules receive an `FS` and never touch `os` directly. +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"`. -### 5.4 `pkg/model` — semantic model +### 5.5 `src/schema` — schema validation -Lowers the CST into a typed structure, and — critically — **discriminates the -scenario** before schema validation runs: +The upstream schemas are vendored into `schemas/` — 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. -```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 -) -``` +Where Go used `go:embed`, Bun uses a **text import**, which embeds the file contents +into the module graph at build time: -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. +```ts +import baseSchema from "../../schemas/devContainer.base.schema.json" with { type: "text" }; +``` -### 5.5 `pkg/schema` — schema validation +In a compiled executable the text is stored once in the engine's own string +representation and handed back without a copy. -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 **Ajv 2019** (`ajv/dist/2019`), which supports draft 2019-09 +including `unevaluatedProperties`. -Validation uses `santhosh-tekuri/jsonschema/v6` (draft 2019-09 + `unevaluatedProperties`). +**Validators are compiled at build time, not at startup.** Ajv's normal mode +generates validator source and evaluates it with `new Function`. That is a poor fit +for a compiled executable with `--bytecode`, and it charges every single invocation +for compiling a 24 KB schema. Instead, `scripts/build-validators.ts` runs Ajv with +`code: { source: true, esm: true }` and writes standalone ESM modules into +`src/schema/generated/`, which are committed and imported like ordinary code. The +schema becomes a build-time input, dead code is eliminated by the bundler, and no +code is generated at runtime. The weekly drift job regenerates these alongside the +vendored schema, so a stale validator is a CI failure rather than a silent +divergence. -**Error translation is a first-class concern.** Raw validator output is routed -through a translation layer that: +**Error translation is a first-class concern.** Ajv reports errors as +`{ instancePath, schemaPath, keyword, params, message }`, where `instancePath` is a +JSON Pointer. Raw 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) + discard errors from the branches that were never applicable. Ajv reports every + failed branch, so an unfiltered run against this schema produces dozens of errors + for a single mistake — this step is what makes the output usable at all. +2. Maps `instancePath` to a CST node for an exact span. The pointer splits into path + segments and goes straight into `findNodeAtLocation(root, path)`, so this is a + lookup rather than a traversal we write ourselves. +3. Rewrites the message into prose, adding the enum's valid values (from + `params.allowedValues`), a spelling suggestion for unknown properties + (Levenshtein over the known key set), and a documentation link. + +Ajv must run with `allErrors: true` so step 1 has a full set to filter. + +### 5.6 `src/rules` — the rule engine + +```ts +interface Rule { + readonly id: string; // e.g. "security/docker-socket-mount" + readonly description: string; + readonly defaultSeverity: Severity; + readonly category: Category; + readonly requiresNetwork: boolean; + check(pass: Pass): void | Promise; } -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) +interface Pass { + readonly doc: Document; // CST + source text + readonly model: DevContainer; + readonly fs: FileSystem; + readonly dir: string; // directory containing devcontainer.json + readonly features: FeatureResolver | undefined; // undefined when offline + readonly signal: AbortSignal; + report(d: Diagnostic): void; } ``` -Rules register themselves in an `init()` into a package-level registry. The engine: +Rules are registered by **explicit import into a manifest**, not by a side effect at +load time. Go's `init()`-based self-registration has no safe equivalent here: module +side effects run on import, and a bundler is free to drop or reorder a module whose +exports are unused. `src/rules/index.ts` lists every rule explicitly. The cost is one +line per rule; the benefit is that tree-shaking, test isolation, and rule ordering +all become predictable, and a rule that was never imported fails a registry +completeness test rather than silently not running. -1. Filters by config (severity `off`) and by `RequiresNetwork()` when offline. -2. Runs rules concurrently — they are pure functions over an immutable `Pass`. +The engine: + +1. Filters by config (severity `off`) and by `requiresNetwork` when offline. +2. Runs offline rules **sequentially**, awaiting `null` between rules to yield to the + event loop. Network-dependent rules run concurrently via `Promise.all`, since they + are I/O-bound and that is where concurrency actually pays. 3. Collects diagnostics, applies inline suppressions, sorts by position. -4. Checks `ctx.Done()` between rules for LSP cancellation. +4. Checks `signal.aborted` between rules for LSP cancellation. + +Point 2 is a deliberate simplification of the Go design, which ran all rules +concurrently. For a document measured in kilobytes the offline rule set is +single-digit milliseconds of pure CPU work; parallelising it across workers would +cost more in structured-clone overhead than it saves. Sequential execution also makes +diagnostic ordering deterministic without a sort key tiebreaker, and removes any +question of two rules observing the model mid-mutation. -### 5.7 `pkg/features` — feature resolution +### 5.7 `src/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. +OCI registry access is plain `fetch` against the distribution API — a token request +against the registry's auth endpoint, then a manifest fetch, then a blob fetch. +No client library is required and none is taken. -### 5.8 `pkg/registry` — extension sources and policy +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 `src/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: @@ -426,19 +618,19 @@ the check is **fully offline**. **Adapter interface:** -```go -type Source interface { - ID() string - RequiresNetwork() bool - Lookup(ctx context.Context, publisher, name string) (*Extension, error) +```ts +interface Source { + readonly id: string; + readonly requiresNetwork: boolean; + lookup(publisher: string, name: string, signal: AbortSignal): Promise; } -type Extension struct { - Version string - Deprecated bool - Downloadable bool // false ⇒ unpublished or removed - AllowedVersions []string // from policy sources; nil ⇒ unconstrained - TargetPlatforms []string +interface Extension { + readonly version: string; + readonly deprecated: boolean; + readonly downloadable: boolean; // false ⇒ unpublished or removed + readonly allowedVersions: string[] | undefined; // from policy sources; undefined ⇒ unconstrained + readonly targetPlatforms: string[]; } ``` @@ -450,7 +642,7 @@ Three implementations: | `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 +#### 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 @@ -463,7 +655,7 @@ bites — *will this extension install for our developers at all* — and it rea 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 +#### 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 @@ -484,32 +676,30 @@ 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. + 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 +#### 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. +Two different concerns are in play, and they have different owners. - **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. + 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. +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 @@ -528,6 +718,8 @@ renamed and never changes meaning. Removal requires a major version. Severities: `error`, `warning`, `info`, `off`. Rules marked 🌐 require `--online`. +This catalog describes the spec, not the implementation language, and is unchanged. + ### 6.1 `syntax/` — parse-level | ID | Default | Description | @@ -625,7 +817,7 @@ Rules marked 🌐 require `--online`. | ID | Default | Description | | --- | --- | --- | -| `lifecycle/shell-syntax-in-array-form` | warning | Array form bypasses the shell; `&&`, `|`, `>` will be literal arguments | +| `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 | @@ -674,13 +866,9 @@ Rules marked 🌐 require `--online`. | --- | --- | --- | | `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. +**Total: 71 rules across 15 categories** — 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. --- @@ -695,6 +883,10 @@ dcx check [flags] [path...] `path` may be a `devcontainer.json` file, or a directory. With no path, the current directory is used. +Argument parsing uses Bun's built-in `parseArgs` (`node:util`). No dependency is +taken for this; the flag set below is entirely expressible in it, and a CLI framework +would be the single largest dependency in the project for the least benefit. + ### 7.2 Target resolution Given a directory, resolve in spec precedence order: @@ -705,7 +897,7 @@ Given a directory, resolve in spec precedence order: 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`. +`.gitignore`. `Bun.Glob` supplies the traversal. ### 7.3 Flags @@ -729,6 +921,9 @@ walks the tree for every dev container config beneath the target, honouring | `--list-rules` | Print the rule catalog (respects `--format json`) | | `--version` | Version, commit, build date | +Colour detection uses `Bun.color` and honours `NO_COLOR`, `FORCE_COLOR`, and TTY +detection in that order. + ### 7.4 Exit codes | Code | Meaning | @@ -738,7 +933,9 @@ walks the tree for every dev container config beneath the target, honouring | 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". +linter broke". Per invariant 1, `process.exit` is called in exactly one place — +`src/cli/main.ts` — and an unexpected exception is caught there, reported as an +internal error, and turned into exit 2. ### 7.5 Text output @@ -760,6 +957,9 @@ linter broke". ✖ 1 error, 2 warnings in 1 file ``` +Caret alignment uses the display-width conversion from §5.2, so the underline lines +up under non-ASCII content rather than drifting. + `compact` format is one line per diagnostic (`file:line:col: severity: message [id]`) for editor `errorformat` integration and grep. @@ -769,16 +969,24 @@ SARIF 2.1.0 with `rules[]` populated from the registry, so GitHub code scanning descriptions and help URIs. This makes the linter a first-class citizen in the GitHub Security tab with no extra work from the user. +Regions are emitted as `startLine`/`startColumn`/`endLine`/`endColumn`. SARIF columns +are 1-based character offsets, which our UTF-16 offsets convert to directly; the +`byteOffset` properties the Go design would have used are optional and are omitted. + --- ## 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 +`.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 parsing uses the `yaml` package. This is the third and last runtime dependency. +Bun has no built-in YAML parser, and hand-rolling one to read a config file would be +the worst kind of not-invented-here. + ```yaml version: 1 @@ -848,6 +1056,10 @@ extensions: satisfy: all ``` +The loaded config is validated by its own Ajv validator, generated by the same +build-time step as the devcontainer schema (§5.5), so a malformed `.dcx.yaml` +produces a diagnostic with a span rather than a runtime type error. + 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 @@ -875,77 +1087,105 @@ differentiator over schema-only validation. - A `meta/unused-suppression` rule (default `warning`) flags directives that suppressed nothing — otherwise suppressions rot silently. +Directives are read from the comment side list produced by the parser adapter +(§5.1), matched to diagnostics by line. + --- ## 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. +CLI rule set is stable (M11), and its existence is what §4.2's invariants pay for. + +It uses `vscode-languageserver-node`, which is the reference implementation of the +protocol rather than a third-party binding — the same codebase VS Code's own language +servers are built on. It is a runtime dependency of the `./server` entry point only +and is declared `optional`, so installing `dcx` for CLI or CI use does not pull it +in. The core library never imports it. ### 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/publishDiagnostics` | `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" | +Hover and completion both need "which node is under the cursor", which is +`findNodeAtOffset()` from the parser adapter — a function we get rather than write. + ### 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. +- Cancellation: each `didChange` aborts the previous analysis via its `AbortController`. +- Positions: offsets are already UTF-16 code units, so an LSP `Position` is a line + lookup and a subtraction (§5.2). +- The server shares the CLI's config discovery, so a project's `.dcx.yaml` governs + the editor identically. -### 9.3 One binary, not two +### 9.3 One package, not two -The server is a **subcommand of the same binary**, not a separate executable: +The server is a **subcommand of the same package**, not a separate artefact: `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 +1. **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. +2. **Users install one thing.** `bun add -d dcx` gives you the CLI and the language + server, and the extension needs nothing further. +3. **There is no packaging pressure to split.** The Go design had to weigh bundling + one binary against two; here both are entry points in a package that the extension + imports directly. + +The `exports` surface in §4.3 stays documented and semver-stable regardless, so +extracting the server later remains possible if it ever grows its own release rhythm. -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. +`extensions/vscode`, TypeScript, deliberately minimal — and substantially smaller +than the Go design's equivalent, because there is no binary to find, ship, version, +or spawn. ### 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 1 (M8) — in-process, direct.** No LSP required. The extension imports the +lint facade directly, calls `analyze()` on open and on save, and populates a +`DiagnosticCollection`. Roughly 100 lines. There is no subprocess, no JSON parsing of +another process's stdout, and no error path for "the binary is missing". -**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. +**Phase 2 (M12) — LSP-driven.** Replace the direct call with +`vscode-languageclient`, running `src/server` in a Node IPC transport. Diagnostics +arrive over the protocol; hover, completion, code actions, and document links come +along with it. The Phase 1 code path is deleted, not maintained in parallel. + +The reason to move to Phase 2 at all is not the extension — Phase 1 serves VS Code +perfectly well. It is Neovim, Helix, and Zed, which need a real server over stdio. ### 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. +None required. The Go design needed a three-step resolution order — `dcx.path` +setting, then a bundled binary in `bin/`, then `PATH` — plus a notification for the +case where all three failed. The extension bundles the analyser as JavaScript and +runs it in the extension host, so none of that exists. + +A `dcx.path` setting is retained for one narrow case: pointing the extension at a +locally built checkout during development on dcx itself. ### 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. +One VSIX, all platforms. `bun build --target=node` bundles the extension and the +analyser into a single JavaScript file of roughly 200 KB, and `vsce package` ships +it. + +This replaces the Go design's six platform-specific VSIX targets, the CI matrix that +copied the right executable into `bin/` before packaging, and the ~6 MB per-platform +payload. It is the single largest simplification in this document. ### 10.4 Activation and settings @@ -955,11 +1195,11 @@ and `workspaceContains:**/.devcontainer.json`. | Setting | Default | Description | | --- | --- | --- | | `dcx.enable` | `true` | Master switch | -| `dcx.path` | `""` | Override binary location | +| `dcx.path` | `""` | Point at a local checkout (development only) | | `dcx.run` | `onSave` | `onSave` \| `onType` | | `dcx.online` | `false` | Enable network rules | | `dcx.configPath` | `""` | Explicit config file | -| `dcx.trace.server` | `off` | LSP tracing | +| `dcx.trace.server` | `off` | LSP tracing (Phase 2) | Commands: *Lint Workspace*, *Fix All Auto-fixable Problems*, *Restart Server*, *Show Output*. @@ -968,36 +1208,60 @@ Commands: *Lint Workspace*, *Fix All Auto-fixable Problems*, *Restart Server*, ## 11. Testing Strategy +`bun test` throughout — Jest-compatible API, built in, no runner dependency and no +transform configuration. + | 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. | +| **Parser adapter** | Table-driven unit tests over the adapter's additions: comment attachment, trailing-comma positions, error surfacing. We do not re-test `jsonc-parser` itself. | +| **Rules** | Golden-file tests: `testdata/rules//.jsonc` with `// want: error: …` annotations inline. The annotation sits on the line the diagnostic must target, so span correctness is tested implicitly. | +| **Schema translation** | Snapshot tests (`toMatchSnapshot`) over the rewritten message for each error class | +| **CLI** | End-to-end tests over `testdata/projects/*` asserting stdout, stderr, and exit code, driven through `Bun.$` | +| **Formatters** | Golden files; SARIF output validated against the SARIF 2.1.0 schema by a generated Ajv validator | +| **Corpus** | A vendored set of ~200 real `devcontainer.json` files harvested from public repos. CI asserts zero exceptions 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 | +| **Property-based** | `fast-check` over the analyser: arbitrary JSONC input must never throw, and every reported range must be within document bounds and must not split a surrogate pair | | **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 +Two notes on what changed. + +**There is no native fuzzer.** Go's `testing.F` with coverage-guided mutation has no +Bun equivalent, and this is a genuine loss — it is the tool that finds the input you +did not think of. `fast-check` with a JSONC-shaped arbitrary plus a mutation pass over +the corpus covers most of the same ground, but through generators we have to write. +The mitigating factor is that the highest-risk component, the parser, is now a +widely-deployed library rather than 700 lines of our own recursive descent. + +**The corpus test remains 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 | +npm is the primary channel. The audience for a `devcontainer.json` linter overwhelmingly +has a JavaScript runtime already, and the payload difference is three orders of +magnitude — a published package of roughly 300 KB against a 78 MB executable. + +| Channel | Mechanism | Payload | +| --- | --- | --- | +| **npm** | `bunx dcx check`, or `bun add -d dcx` / `npm i -D dcx` | ~300 KB | +| **Executables** | `bun build --compile --target=` for the 8 supported targets; attached to GitHub Releases with checksums and SBOM | ~78 MB each | +| **Homebrew** | Tap wrapping the executable | ~78 MB | +| **Docker** | `ghcr.io/lonhutt/dcx`, `oven/bun`-based | ~120 MB | +| **pre-commit** | `.pre-commit-hooks.yaml` with a `node` hook, plus a binary-download hook | — | +| **GitHub Action** | Composite action running `bunx dcx`, uploading SARIF | — | +| **VSCode** | Marketplace + Open VSX, one VSIX for all platforms | ~200 KB | + +Executables exist for the environments that genuinely have no runtime — a distroless +CI image, a bootstrapping script — and are honestly labelled as the heavyweight +option. Note that `bun build --compile` cross-compiles from any host to all eight +targets, so the release job is a single-runner loop rather than a matrix of runners. + +Scoop and the Linux packages (`.deb`/`.rpm`/`.apk`) from the Go design are dropped. +GoReleaser produced them nearly for free; here each would be hand-rolled packaging +around a 78 MB payload for an audience already served by npm. ### 12.1 Versioning policy @@ -1007,34 +1271,42 @@ Semantic versioning. Rule IDs are public API: - **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. +The `exports` map in §4.3 is versioned on the same policy: adding a subpath is minor, +removing or narrowing one is major. + --- ## 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 | +| **M0** | Foundations | Repo, `package.json`, CI (test/typecheck/lint), `position`, `vfs`, `diagnostic` modules | CI green; `position` round-trips the Unicode test corpus | +| **M1** | JSONC adapter | `jsonc-parser` wrapper, comment side-list and attachment, trailing-comma positions, `Document` type | Parses the 200-file corpus with zero exceptions; comment attachment verified against fixtures | +| **M2** | Schema layer | Vendored schemas, text imports, build-time Ajv standalone generation, scenario discrimination, error translation | Every schema error class yields a human-readable message with an exact span | +| **M3** | Rule engine | `Rule` interface, manifest registry, sequential execution, config file, inline suppressions | Engine runs with a trivial rule set; suppressions tested; registry completeness test passes | | **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 | +| **M7** | Reporters + release | SARIF, GitHub annotations, npm publish, executables, Docker, pre-commit, GH Action | `v0.1.0` published and installable via `bunx` | +| **M8** | VSCode extension v1 | In-process diagnostics, single 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 | +| **M10** | Fixes | `fix` on fixable rules via `modify()`, `--fix`, `--fix-dry-run`, convergence tests | All rules marked *fixable* apply cleanly | +| **M11** | LSP server | `src/server`, 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. +M1, M8, and M10 are materially cheaper than their Go equivalents — the parser is a +wrapper rather than a recursive-descent implementation, the extension ships no +binary, and fixes are expressed as `modify()` calls against JSON paths rather than +hand-computed edit spans. M0 and M2 are slightly more expensive: `position` carries +display-width handling the Go design underspecified, and M2 gains a build-time +codegen step. + --- ## 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`. | @@ -1045,6 +1317,8 @@ Every question from the review draft is now closed. | 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. | +| 9 | Runtime dependency budget | **Three in the core, and they are named:** `jsonc-parser`, `ajv`, `yaml`. Anything else must displace one of them or be written in-tree. The `./server` entry point adds `vscode-languageserver` as an optional dependency, not installed for CLI use. | +| 10 | Node compatibility | **Bun is the development and primary runtime; the published package must also run on Node 22+.** Bun-specific APIs (`Bun.file`, `Bun.Glob`, `Bun.stringWidth`) are confined to `src/vfs`, `src/cli`, and `src/position`, each behind a narrow interface with a Node fallback. The core is runtime-agnostic, which is also what lets the VSCode extension host run it unchanged. | --- @@ -1071,8 +1345,8 @@ platform-qualified versions (`"5.0.0@win32-x64"`). Whether a devcontainer's own 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). +- **Do:** add `token-command` to the source schema; execute it via `Bun.$`, 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. @@ -1082,8 +1356,8 @@ want `token-command: gh auth token` so no long-lived token sits in the environme 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. +- **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 @@ -1092,22 +1366,90 @@ engine, reporters, and CLI all apply unchanged. 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. +### D4 — Worker-parallel corpus linting — *low* + +`--recursive` over a monorepo with hundreds of dev container configs is the one case +where single-threaded analysis (§5.6) could become noticeable. If it does, the fix is +`Worker` over *files*, not over rules: each worker owns a document end to end, so the +structured-clone cost is one string in and one diagnostic array out. + +- **Trigger:** a measured `--recursive` run exceeding ~2 s on a real repository. +- **Blocked by:** M5. **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` | +| Language | TypeScript on Bun | 9 ms measured start; the whole target ecosystem — parser, LSP framework, extension host — is TypeScript | +| Parser | `jsonc-parser` behind a thin adapter | It is the parser VS Code uses; agreeing with the editor becomes structural, not aspirational | +| Schema | Vendored + text import, Ajv 2019 compiled standalone at build time | Offline-deterministic; draft 2019-09 + `unevaluatedProperties`; no runtime codegen | | 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 | +| Filesystem | `FileSystem` interface everywhere | Unsaved editor buffers are the whole reason an LSP needs it | +| Positions | UTF-16 code units internally; display width at the terminal edge | The unit the parser, the language, and the protocol already share | +| Rule registration | Explicit manifest, not load-time side effects | Bundler-safe and testable; `init()` has no safe equivalent | +| Concurrency | Sequential offline, concurrent for network I/O | Parallelism where it pays; determinism where it doesn't | +| Fixes | `Diagnostic.fix` from rule #1, via `modify()` | Retrofitting fixes means rewriting every rule; format-preserving edits come free | | 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 | +| LSP location | `dcx serve`, same package | One thing to install and version | +| Extension | Phase 1 in-process, Phase 2 LSP | Ships value early; Phase 2 exists for Neovim and Zed, not for VS Code | +| Distribution | npm primary, executables secondary | 300 KB against 78 MB, for an audience that has a runtime already | + +--- + +## 17. Assessment + +What this language choice actually costs and buys, stated plainly. + +### 17.1 What got better + +**The parser stops being ours.** §5.1 falls from ~700 lines of hand-written lexer and +recursive-descent parser to a ~150 line adapter, and — more importantly — the +remaining risk moves from our code to Microsoft's. For a tool whose correctness is +defined as *agreeing with VS Code about what this file says*, using VS Code's parser +is not a convenience but a correctness argument. + +**The extension stops being a distribution problem.** Six platform-specific VSIX +targets, a CI matrix to place the right executable in `bin/`, a three-step binary +resolution order, and the whole class of "the bundled binary doesn't match the +extension version" bug are deleted rather than ported. One VSIX, ~200 KB, everywhere. + +**Fixes get cheaper.** `modify()` / `applyEdits()` perform format-preserving edits +against JSON paths, so M10 stops being an exercise in hand-computed edit arithmetic. + +**Positions get simpler.** UTF-16 offsets are what the parser emits, what the +language indexes strings by, and what the protocol is defined in. The conversion the +Go design had to perform on the editor's hottest path does not exist. + +### 17.2 What got worse + +**Binary size: 78 MB against roughly 2 MB, measured.** This is the real loss and +there is no mitigation that makes it go away — only the observation that npm, not the +executable, is now the path almost everyone takes. Anyone who genuinely needs a +runtime-free single file is worse off by a factor of forty. + +**No coverage-guided fuzzer.** `fast-check` covers similar ground through generators +we write rather than mutation the tool discovers. Partly offset by the parser no +longer being ours to fuzz. + +**A dependency tree.** Three runtime dependencies against Go's near-zero-dependency +norm, plus a transitive graph and a supply chain to watch. Decision 9 caps it. + +**Single-threaded.** Immaterial for one document, potentially material for +`--recursive` over a monorepo. D4 holds the escape hatch. + +### 17.3 The argument that did not survive + +The Go design's §2.1 rested on TypeScript costing 150–300 ms to start. That figure +describes Node. Bun starts this CLI in **9 ms median over 30 runs**, of which 3 ms is +process-spawn overhead any language pays — against the ~5 ms the Go design claimed +for itself. A 4 ms difference on a tool a human invokes on save is not a +differentiator, and it was the load-bearing argument for compiling ahead of time. + +What remains of the original case for Go is binary size, and binary size mattered +chiefly *because* the extension had to bundle the thing. In TypeScript it does not. +The two costs were load-bearing for each other, and neither stands alone. 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/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/position/testdata/large-commented.jsonc b/pkg/position/testdata/large-commented.jsonc deleted file mode 100644 index 33c54ec..0000000 --- a/pkg/position/testdata/large-commented.jsonc +++ /dev/null @@ -1,1558 +0,0 @@ -// ============================================================================ -// DevContainer — Ambiente de Desenvolvimento Controlado - v5.9.1 -// Projeto: chatgpt-docker-puppeteer -// -// Propósito: -// Definir um ambiente de desenvolvimento determinístico, auditável e -// arquiteturalmente neutro, no qual: -// -// • A infraestrutura fornece CAPACIDADE (runtime, rede, volumes) -// • O sistema decide COMPORTAMENTO (topologia, browser, orquestração) -// • O editor (VS Code) não interfere em planos de controle internos -// -// Princípios: -// • Deny-by-default para portas -// • Puppeteer opera exclusivamente em modo "connect" -// • Chrome é externo ao container (host ou serviço dedicado) -// • Nenhuma decisão de topologia é hardcoded na infraestrutura -// • Debug e observabilidade são opt-in e não intrusivos -// • Documentação robusta (NUNCA reduzir) -// -// Escopo: -// • Ambiente DEV (não produção) -// • Suporte a Puppeteer, Node.js, PM2, Docker CLI (host socket) -// • Estabilidade e previsibilidade > conveniência automática -// -// Nota Arquitetural Importante: -// Portas de controle (ex.: Chrome Proxy / Debug) não são auto-expostas por -// inferência. Quando declaradas, são forwardadas explicitamente e com política -// de UX silenciosa/ignore. O acesso operacional continua decidido pelo runtime. -// -// CHANGELOG v5.9.1 (2026-08-18): -// 🌐 NETWORK OBSERVABILITY + CONTROL-PLANE PATH SELF-HEALING -// ✅ ALINHADO: Dockerfile v1.5.1; post-create v1.2.3; post-start v3.0.3. -// ✅ ALINHADO: local-dns-cache v1.8.1; network-control-plane-state v1.1.1. -// ✅ CORRIGIDO: caminho canônico do Network Control Plane sem o segmento `network/` espúrio. -// ✅ CORRIGIDO: versões/labels ativos do DevContainer e da imagem, antes divergentes do Dockerfile canônico. -// ✅ ENDURECIDO: hooks recuperam override runtime stale/ilegível usando o script canônico existente. -// ✅ MANTIDO: DNS default-on fail-safe; proxy local opt-in; benchmarks longos fora do boot. -// -// CHANGELOG v5.9.0 (2026-05-20): -// 🌐 DEFAULT-ON DNS + POST-START 3.0.2 + CONTROL PLANE STATE SYNC -// ✅ ALINHADO: Dockerfile v1.4.2 -// ✅ ALINHADO: package.json v1.1.3 -// ✅ ALINHADO: Makefile v4.3.0 -// ✅ ALINHADO: post-create v1.2.2 -// ✅ ALINHADO: post-start v3.0.2 -// ✅ ALINHADO: post-attach v5.9.0 -// ✅ ALINHADO: healthcheck v3.0.0 -// ✅ ALINHADO: sync-local-auth v2.0.0 -// ✅ ALINHADO: network-control-plane-state v1.1.0 -// ✅ ALINHADO: github-api-route-fix v1.9.1 -// ✅ ALINHADO: local-dns-cache v1.8.0 -// ✅ ALINHADO: local-copilot-proxy v1.3.1 -// ✅ ALINHADO: github-copilot-network-manager v1.6.1 -// ✅ ALINHADO: copilot-route-advisor v1.1.0 -// ✅ ALINHADO: endpoints.github-copilot.tsv v1.2.0 -// ✅ CORRIGIDO: post-start agora usa action=start para o manager, gerando -// snapshot runtime bounded em vez de apenas recomendação stale. -// ✅ CORRIGIDO: DNS local default-on com prova forte antes de governar resolv.conf. -// ✅ ADICIONADO: knobs v1.8.0 para Docker embedded DNS split-horizon, warmup, -// ranking sem benchmark no boot, stale detection e probe forte. -// ✅ ADICIONADO: network-control-plane-state como agregador passivo pós-start. -// ✅ ADICIONADO: separação explícita entre summary runtime e action.summary. -// ✅ MANTIDO: proxy local desligado por padrão; nenhum HTTP(S)_PROXY global. -// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece bounded. -// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.9.0. -// -// CHANGELOG v5.8.0 (2026-05-19): -// 🧭 CANONICAL PATH REALIGNMENT + CONTROL PLANE SYNC -// ✅ ALINHADO: Dockerfile v1.4.2 -// ✅ ALINHADO: post-create v1.1.0 -// ✅ ALINHADO: post-start v2.8.1 -// ✅ ALINHADO: post-attach v5.7.1 -// ✅ ALINHADO: github-api-route-fix v1.8.6 -// ✅ ALINHADO: local-dns-cache v1.5.3 -// ✅ ALINHADO: local-copilot-proxy v1.2.3 -// ✅ ALINHADO: github-copilot-network-manager v1.5.3 -// ✅ ALINHADO: copilot-route-advisor v1.0.1 -// ✅ ALINHADO: nss-gatekeeper v2.1.2 -// ✅ CORRIGIDO: endpoint registry aponta para .devcontainer/scripts/network/ -// ✅ CORRIGIDO: aliases DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY(_FILE) -// ✅ CORRIGIDO: remoção do override DEVCONTAINER_COPILOT_PROBE_ENDPOINTS -// para permitir que o registry seja a fonte de verdade. -// ✅ CORRIGIDO: knobs do local-copilot-proxy para nomes realmente consumidos -// pelo script v1.2.3. -// ✅ ADICIONADO: metadados DEVCONTAINER_VERSION / CONTROL_PLANE_GENERATION. -// ✅ ADICIONADO: limites explícitos de consumo do endpoint registry. -// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece recommend/quick. -// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global. -// ✅ MANTIDO: DNS cache local em auto/ranked com fail-closed. -// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.8.0. -// -// // CHANGELOG v5.7.0 (2026-05-17): -// 🧭 NETWORK CONTROL PLANE + SAFE BENCHMARK INTEGRATION -// ✅ ALINHADO: Dockerfile v1.4.2 -// ✅ ALINHADO: package.json v1.1.0 -// ✅ ALINHADO: Makefile v4.2.0 -// ✅ ALINHADO: post-create v1.0.4 -// ✅ ALINHADO: post-start v2.8.0 -// ✅ ALINHADO: post-attach v5.7.0 -// ✅ ALINHADO: github-api-route-fix v1.8.4 -// ✅ ALINHADO: local-copilot-proxy v1.2.2 -// ✅ ALINHADO: github-copilot-network-manager v1.5.0 -// ✅ ADICIONADO: defaults formais para benchmark prolongado manual/opt-in -// ✅ ADICIONADO: recommendation artifacts e policy bridge sem aplicação automática -// ✅ ADICIONADO: endpoint registry oficial para GitHub/Copilot -// ✅ ADICIONADO: superfície Copilot ampliada (origin-tracker + telemetry) -// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global -// ✅ MANTIDO: benchmark longo fora do boot; post-start apenas quick/recommend -// ✅ ATUALIZADO: build.args.VERSION e labels para Dockerfile 1.4.2 / devcontainer 5.7.0 -// -// CHANGELOG v5.6.0 (2026-05-16): -// 🌐 DNS CACHE PROMOTION + NETWORK HARDENING -// ✅ ALINHADO: Dockerfile v1.4.2 -// ✅ ALINHADO: post-start v2.8.0 -// ✅ ALINHADO: github-api-route-fix v1.8.4 -// ✅ ALINHADO: local-dns-cache v1.5.1 -// ✅ ALINHADO: local-copilot-proxy v1.2.2 -// ✅ ALINHADO: github-copilot-network-manager v1.5.0 -// ✅ PROMOVIDO: DNS cache local habilitado por padrão em modo auto/ranked -// ✅ ADICIONADO: hardening de dnsmasq start/repair/ownership/port-check -// ✅ ADICIONADO: thresholds e locks explícitos para GitHub/Copilot manager -// ✅ ADICIONADO: verificação estrita de IP aplicado no route-fix -// ✅ MANTIDO: proxy local desligado por padrão até teste controlado posterior -// ✅ CORRIGIDO: DEVCONTAINER_MAKE_TIMEOUT=30, validado após PM2 warmup -// -// CHANGELOG v5.5.0 (2026-05-16): -// 🌐 NETWORK ARCHITECTURE SYNC (GITHUB/COPILOT) -// ✅ ALINHADO: Dockerfile v1.4.2 -// ✅ ALINHADO: post-start v2.8.0 -// ✅ ALINHADO: github-api-route-fix v1.8.4 -// ✅ ALINHADO: local-dns-cache v1.2.0 -// ✅ ALINHADO: local-copilot-proxy v1.2.2 -// ✅ ALINHADO: github-copilot-network-manager v1.5.0 -// ✅ ADICIONADO: Copilot Network Manager habilitado por padrão -// ✅ ADICIONADO: DNS cache local e proxy local como infraestrutura opt-in -// ✅ CORRIGIDO: CODEX_HOME aponta para ${containerWorkspaceFolder}/.codex -// ✅ CORRIGIDO: lifecycle hooks quotados e chamados por bash explicitamente -// ✅ CORRIGIDO: build.args.VERSION e labels para 1.4.0 / devcontainer 5.5.0 -// -// CHANGELOG v5.4.0 (2026-05-15): -// 🔧 DOCKERFILE/NSS SYNC (CANONICAL ALIGNMENT) -// ✅ ALINHADO: Dockerfile.canonical.v1.3 -// ✅ ALINHADO: nss-gatekeeper canonical v2.0.0 -// ✅ ALINHADO: post-start v2.5.0 -// ✅ ALINHADO: post-attach v5.4.0 -// ✅ CORRIGIDO: build.args.VERSION 1.0 → 1.3 -// ✅ CORRIGIDO: LD_PRELOAD absoluto/canônico em containerEnv + remoteEnv -// ✅ CORRIGIDO: portsAttributes wildcard → otherPortsAttributes oficial -// ✅ ADICIONADO: GitHub.copilot explicitamente junto de GitHub.copilot-chat -// ✅ ADICIONADO: overrideCommand=false para honrar CMD/ENTRYPOINT da imagem -// ✅ ADICIONADO: labels runtime devcontainer.version e image.version -// -// CHANGELOG v5.3 (2026-02-03): -// 🔧 SSH FORWARDING MIGRATION (BREAKING CHANGE - FIX CRÍTICO) -// ❌ REMOVIDO: Mount manual de SSH socket (causava erro fatal) -// ❌ REMOVIDO: SSH_AUTH_SOCK hardcoded em remoteEnv -// ❌ REMOVIDO: DEVCONTAINER_SECRET_SURFACE_SSH (redundante) -// ❌ REMOVIDO: DEVCONTAINER_SSH_AGENT_ALLOWED (containerEnv) -// ✅ ADICIONADO: VS Code native SSH forwarding (automático) -// ✅ ADICIONADO: Documentação completa da migração -// ✅ RESOLVIDO: Container agora inicia COM ou SEM SSH agent -// -// REFERÊNCIAS: -// • .devcontainer/MIGRATION_SSH_V5.3.md (documentação completa) -// • .devcontainer/TROUBLESHOOTING_SSH.md (guia de debug) -// • DEVCONTAINER_BUILD_ANALYSIS.md (análise técnica) -// -// CHANGELOG v5.2 (2026-02-02): -// ✅ Sincronização de versão (3.4 → 5.2) -// ✅ Features consolidadas (removidas duplicações) -// ✅ Extensions agrupadas por categoria -// ✅ Scrollback otimizado (20000 → 10000) -// ✅ Documentação de segurança melhorada (--group-add=docker) -// ✅ remoteEnv consolidado (removidas duplicações) -// ============================================================================ -{ - // ============================================================ - // BUILD CONFIGURATION (v5.9.1 — Dockerfile v1.5.1 sync) - // ============================================================ - "build": { - "args": { - "BUILD_DATE": "${localEnv:BUILD_DATE}", - "BUILD_ENV": "dev", - // 🔐 SINCRONIZAÇÃO DOCKER - "DOCKER_GID": "${localEnv:DOCKER_GID}", - "IMAGE_NAME": "chatgpt-docker-puppeteer", - "IMAGE_VENDOR": "Yuri", - "PROJECT_NAME": "${localWorkspaceFolderBasename}", - // --- O APERTO DE MÃO (Identidade) --- - // REMOTE_USER: Sincroniza remoteUser com Dockerfile (torna imagem dinâmica) - // Dockerfile usa: ENV USER_NAME=${REMOTE_USER} - // NOTA: ${containerUser} NÃO existe como variável built-in do DevContainers. - // Usar valor literal que corresponde ao remoteUser (linha ~998) - "REMOTE_USER": "node", - "VCS_REF": "${localEnv:GIT_COMMIT}", - // --- METADATA & VERSIONING --- - "VERSION": "1.5.1", - // --- NETWORK CONTROL PLANE DEFAULTS --- - // Usados apenas como defaults de imagem; benchmark longo permanece manual. - "NETWORK_BENCHMARK_DURATION_SECONDS": "${localEnv:NETWORK_BENCHMARK_DURATION_SECONDS:600}", - "NETWORK_BENCHMARK_INTERVAL_SECONDS": "${localEnv:NETWORK_BENCHMARK_INTERVAL_SECONDS:10}", - "NETWORK_RECOMMENDATION_TTL_SECONDS": "${localEnv:NETWORK_RECOMMENDATION_TTL_SECONDS:86400}" - }, - "context": "..", - "dockerfile": "Dockerfile" - }, - // ============================================================ - // CONTAINER ENVIRONMENT VARIABLES (v5.9.1 — NSS/Dockerfile/network sync) - // ============================================================ - "containerEnv": { - // canonical shell contract mirrored from /etc/profile.d/00-runtime.sh - // ensures non‑login terminals inherit the same semantic hints as - // interactive logins. purely informational, used by helpers and tests. - "SHELL_CANONICAL": "bash", - "CANONICAL_SHELL": "bash", - "INSTRUMENTAL_SHELLS": "pwsh", - "DEVCONTAINER_MAKE_TIMEOUT": "30", - - "APP_ENV": "dev", - "APP_NAME": "chatgpt-docker-puppeteer", - "APP_ROLE": "agent", - "DEVCONTAINER_VERSION": "5.9.1", - "DEVCONTAINER_CONTROL_PLANE_GENERATION": "network-control-plane-v5.9.1", - "DEVCONTAINER_CANONICAL_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", - "DEVCONTAINER_POST_START_SCRIPT_VERSION_EXPECTED": "3.0.3", - "DEVCONTAINER_POST_ATTACH_SCRIPT_VERSION_EXPECTED": "5.9.1", - "DEVCONTAINER_HEALTHCHECK_SCRIPT_VERSION_EXPECTED": "3.0.1", - "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT_VERSION_EXPECTED": "1.1.1", - "DEVCONTAINER_SYNC_LOCAL_AUTH_SCRIPT_VERSION_EXPECTED": "2.0.0", - // CHOKIDAR_USEPOLLING e CHOKIDAR_INTERVAL removidos em v5.4.0: - // O workspace é montado como ext4 (inotify nativo funciona). - // Vite já configura polling explicitamente em vite.config.js (watch.usePolling: true). - // Os watchers do servidor usam fs.watch() nativo — não dependem de chokidar. - // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-001] - "DEBUG": "puppeteer:*,agent:*", - "DEVCONTAINER_DOCKER_ENABLED": "true", - "DOCKER_BUILDKIT": "1", - "DOCKER_HOST_ACCESS": "true", - "DOCKER_SECURITY_LEVEL": "host-root-equivalent", - // Canonical NSS artifact directory. - // Used by profile.d + nss-gatekeeper; safe as plain metadata in containerEnv. - "DEVCONTAINER_NSS_DIR": "/tmp/devcontainer-nss", - // ========================================================= - // NETWORK RESILIENCE — GitHub/Copilot route manager - // --------------------------------------------------------- - // Smart route selector for api.github.com, delegated by - // post-start.sh to .devcontainer/scripts/network/github-api-route-fix.sh. - // It repairs ISP/DNS edge failures without changing Windows/host network. - // ========================================================= - "DEVCONTAINER_ENABLE_GITHUB_API_ROUTE_FIX": "true", - "DEVCONTAINER_GITHUB_API_HOST": "api.github.com", - "DEVCONTAINER_GITHUB_API_FUNCTIONALITY_PROFILE": "copilot", - "DEVCONTAINER_GITHUB_API_BENCHMARK_DURATION_SECONDS": "600", - "DEVCONTAINER_GITHUB_API_BENCHMARK_INTERVAL_SECONDS": "10", - "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_SAMPLES": "0", - "DEVCONTAINER_GITHUB_API_BENCHMARK_INCLUDE_CANDIDATES": "true", - "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_CACHE": "true", - "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMEND_MIN_SAMPLES": "5", - "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", - "DEVCONTAINER_GITHUB_API_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", - "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMENDATION_TTL_SECONDS": "86400", - "DEVCONTAINER_GITHUB_API_ROUTE_PROXY_MODE": "auto", - "DEVCONTAINER_GITHUB_API_MIN_SCORE": "85", - "DEVCONTAINER_GITHUB_API_MAX_CANDIDATES": "16", - "DEVCONTAINER_GITHUB_API_PARALLEL_PROBES": "true", - "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_ENABLED": "true", - "DEVCONTAINER_GITHUB_API_ENABLE_IPV6": "false", - "DEVCONTAINER_GITHUB_API_OPENSSL_PREFLIGHT": "false", - "DEVCONTAINER_GITHUB_API_STRICT_VERIFY_EXPECTED_IP": "true", - "DEVCONTAINER_GITHUB_API_CACHE_LOCK_WAIT_SECONDS": "10", - "DEVCONTAINER_GITHUB_API_HOSTS_LOCK_WAIT_SECONDS": "15", - "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_ENTRIES": "128", - "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_AGE_SECONDS": "86400", - "DEVCONTAINER_GITHUB_API_ACTION_UPDATE_RUNTIME_SUMMARY": "false", - "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_RUNTIME_SUMMARY": "false", - "DEVCONTAINER_GITHUB_API_ROUTE_CONNECT_TIMEOUT": "4", - "DEVCONTAINER_GITHUB_API_ROUTE_MAX_TIME": "12", - "DEVCONTAINER_GITHUB_API_VERSION": "2022-11-28", - "DEVCONTAINER_GITHUB_API_ROLLBACK_ON_VERIFY_FAILURE": "true", - "DEVCONTAINER_GITHUB_API_OPTIMIZE_WHEN_CURRENT_OK": "false", - "DEVCONTAINER_GITHUB_API_HYSTERESIS_SCORE_MARGIN": "5000", - "DEVCONTAINER_GITHUB_API_RECENT_FAILURE_HARD_BLOCK_SECONDS": "0", - "DEVCONTAINER_SKIP_GITHUB_API_PROBES_AFTER_ROUTE_FIX": "true", - - // ========================================================= - // NETWORK RESILIENCE — Modular GitHub/Copilot layer (v5.5.0) - // --------------------------------------------------------- - // Camada 1: github-api-route-fix.sh corrige ativamente apenas api.github.com. - // Camada 2: github-copilot-network-manager.sh agrega route-fix + probes. - // Camada 3: local-dns-cache.sh e local-copilot-proxy.sh existem como - // infraestrutura opt-in, não ativada automaticamente. - // - // Política: - // • ativo por padrão: manager + route-fix de api.github.com; - // • opt-in: cache DNS local e proxy HTTP CONNECT local; - // • IPv6 para api.github.com permanece off até haver AAAA real funcional. - // ========================================================= - "DEVCONTAINER_ENABLE_COPILOT_NETWORK_MANAGER": "true", - "DEVCONTAINER_COPILOT_NETWORK_MANAGER_MODE": "active", - "DEVCONTAINER_COPILOT_NETWORK_MANAGER_POST_START_ACTION": "start", - "DEVCONTAINER_COPILOT_TRANSPORT_PROFILE": "auto", - "DEVCONTAINER_POST_START_APPLY_TRANSPORT_RECOMMENDATION": "false", - "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_DURATION_SECONDS": "600", - "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_INTERVAL_SECONDS": "10", - "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_MAX_SAMPLES": "0", - "DEVCONTAINER_COPILOT_NETWORK_RECOMMENDATION_TTL_SECONDS": "86400", - // Endpoint registry canônico: - // • o arquivo vive junto dos scripts de rede em .devcontainer/scripts/network/ - // • ambos os aliases são definidos porque post-start, post-attach, - // github-copilot-network-manager e post-create aceitam nomes diferentes. - // • NÃO definir DEVCONTAINER_COPILOT_PROBE_ENDPOINTS aqui; quando presente, - // ele sobrescreve o registry e impede que a allowlist TSV governe os probes. - "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", - "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY_FILE": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", - "DEVCONTAINER_COPILOT_USE_ENDPOINT_REGISTRY": "true", - "DEVCONTAINER_POST_START_ENDPOINT_REGISTRY_MAX_ROWS": "64", - "DEVCONTAINER_COPILOT_MANAGER_MAX_ENDPOINTS": "64", - "DEVCONTAINER_COPILOT_MANAGER_RUN_API_ROUTE_FIX": "true", - "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_DEGRADED": "false", - "DEVCONTAINER_COPILOT_MANAGER_SUBSCRIPT_TIMEOUT_SECONDS": "90", - "DEVCONTAINER_COPILOT_WARN_TOTAL_MS": "1500", - "DEVCONTAINER_COPILOT_PROBE_CONNECT_TIMEOUT": "4", - "DEVCONTAINER_COPILOT_PROBE_MAX_TIME": "12", - "DEVCONTAINER_COPILOT_PROBE_IP_FAMILY": "4", - "DEVCONTAINER_COPILOT_PROBE_PROXY_MODE": "auto", - "DEVCONTAINER_COPILOT_PROBE_PARALLEL": "true", - "DEVCONTAINER_COPILOT_NETWORK_LOCK_WAIT_SECONDS": "30", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_LOCK_WAIT_SECONDS": "10", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_ENABLED": "true", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_MAX_LINES": "2000", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_WINDOW": "40", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_SLOW_THRESHOLD": "1500", - "DEVCONTAINER_COPILOT_NETWORK_HISTORY_FAIL_THRESHOLD": "3", - "DEVCONTAINER_COPILOT_MANAGER_PARALLEL_PROBES": "true", - "DEVCONTAINER_COPILOT_MANAGER_MARK_UNSTABLE_AS_DEGRADED": "true", - "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_UNSTABLE": "false", - "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_ENDPOINTS": "false", - "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_GITHUB_API_HOST": "false", - // DEVCONTAINER_COPILOT_PROBE_ENDPOINTS deliberadamente omitido. - // Fallbacks permanecem nos scripts; a fonte primária agora é o TSV canônico. - - // DNS cache local: default-on em modo auto/ranked com local-dns-cache v1.8.0. - // O script só escreve /etc/resolv.conf depois de prova local forte - // (dig/drill/nslookup), preserva search/domain e mantém split-horizon - // para Docker embedded DNS quando 127.0.0.11 existir no runtime. - "DEVCONTAINER_ENABLE_LOCAL_DNS_CACHE": "true", - "DEVCONTAINER_LOCAL_DNS_CACHE_MODE": "auto", - "DEVCONTAINER_LOCAL_DNS_MODE": "auto", - "DEVCONTAINER_LOCAL_DNS_POST_START_ACTION": "start", - "DEVCONTAINER_LOCAL_DNS_CACHE_ACTION": "start", - "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_OFF": "true", - "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_FAILURE": "true", - "DEVCONTAINER_LOCAL_DNS_BIND_ADDRESS": "127.0.0.1", - "DEVCONTAINER_LOCAL_DNS_BIND_PORT": "53", - "DEVCONTAINER_LOCAL_DNS_UPSTREAM_SELECTION": "ranked", - "DEVCONTAINER_LOCAL_DNS_UPSTREAMS": "1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112", - "DEVCONTAINER_LOCAL_DNS_BENCHMARK_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com", - "DEVCONTAINER_LOCAL_DNS_START_MODE": "auto", - "DEVCONTAINER_LOCAL_DNS_WRITE_RESOLV_CONF": "true", - "DEVCONTAINER_LOCAL_DNS_REPAIR_ON_PROBE_FAILURE": "true", - "DEVCONTAINER_LOCAL_DNS_VALIDATE_CONFIG": "true", - "DEVCONTAINER_LOCAL_DNS_STRICT_PORT_CHECK": "true", - "DEVCONTAINER_LOCAL_DNS_TAKEOVER_STALE_DNSMASQ": "true", - "DEVCONTAINER_LOCAL_DNS_STOP_BY_SOCKET_OWNER": "true", - "DEVCONTAINER_LOCAL_DNS_STOP_WAIT_MS": "2000", - "DEVCONTAINER_LOCAL_DNS_FORWARD_MAX": "150", - "DEVCONTAINER_LOCAL_DNS_CACHE_SIZE": "10000", - "DEVCONTAINER_LOCAL_DNS_MIN_CACHE_TTL": "0", - "DEVCONTAINER_LOCAL_DNS_MAX_CACHE_TTL": "300", - "DEVCONTAINER_LOCAL_DNS_NEG_TTL": "30", - "DEVCONTAINER_LOCAL_DNS_RESOLV_OPTIONS": "timeout:1 attempts:2 rotate", - "DEVCONTAINER_LOCAL_DNS_READ_ETC_HOSTS": "false", - "DEVCONTAINER_LOCAL_DNS_LOG_QUERIES": "false", - "DEVCONTAINER_LOCAL_DNS_ENABLE_IPV6_UPSTREAMS": "false", - "DEVCONTAINER_LOCAL_DNS_ALL_SERVERS": "false", - "DEVCONTAINER_LOCAL_DNS_STRICT_ORDER": "false", - "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE": "false", - "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE_TTL": "60", - "DEVCONTAINER_LOCAL_DNS_ACTION_UPDATE_RUNTIME_SUMMARY": "false", - "DEVCONTAINER_LOCAL_DNS_REQUIRE_PROVEN_LOCAL_PROBE_FOR_RESOLV_CONF": "true", - "DEVCONTAINER_LOCAL_DNS_ALLOW_PROCESS_ONLY_LOCAL_PROBE": "false", - "DEVCONTAINER_LOCAL_DNS_REPAIR_STALE_PIDFILE": "true", - "DEVCONTAINER_LOCAL_DNS_PRESERVE_RESOLV_SEARCH": "true", - "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_MODE": "auto", - "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_RESOLVER": "127.0.0.11", - "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_UNQUALIFIED": "true", - "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_SEARCH_DOMAINS": "true", - "DEVCONTAINER_LOCAL_DNS_REBIND_OK_DOCKER_DOMAINS": "true", - "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_FAILURE": "true", - "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_STOP": "true", - "DEVCONTAINER_LOCAL_DNS_WARMUP": "true", - "DEVCONTAINER_LOCAL_DNS_WARMUP_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com default.exp-tas.com origin-tracker.githubusercontent.com", - "DEVCONTAINER_LOCAL_DNS_WARMUP_MAX_HOSTS": "8", - "DEVCONTAINER_LOCAL_DNS_WARMUP_RECORD_TYPES": "A", - "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_ON_START": "false", - "DEVCONTAINER_LOCAL_DNS_FORCE_REBENCHMARK": "false", - "DEVCONTAINER_LOCAL_DNS_RANKING_MAX_AGE_SECONDS": "86400", - "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_MIN_SECONDS": "900", - "DEVCONTAINER_LOCAL_DNS_RANKING_HYSTERESIS_SCORE_MARGIN": "5000", - "DEVCONTAINER_LOCAL_DNS_STATUS_STALE_MAX_SECONDS": "300", - "DEVCONTAINER_LOCAL_DNS_FAST_RETRY": "false", - - // Proxy HTTP CONNECT local: disponível na imagem v1.5.1, mas desligado por padrão. - // Para habilitar de forma explícita: DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY=true - // e DEVCONTAINER_COPILOT_PROXY_MODE=local. - "DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY": "false", - "DEVCONTAINER_COPILOT_PROXY_MODE": "off", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_MODE": "off", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_HOST": "127.0.0.1", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_PORT": "3128", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_APPLY_PROFILE": "false", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_STATUS_STRICT": "false", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_USE_ENDPOINT_REGISTRY": "true", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_CUSTOM_PROBE_URLS": "false", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_NON_LOOPBACK": "false", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_CONNECT_PORTS": "443", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_REMOVE_PROFILE_ON_STOP": "true", - "DEVCONTAINER_POST_START_SOURCE_LOCAL_PROXY_ENV": "false", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_DURATION_SECONDS": "600", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_INTERVAL_SECONDS": "10", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_SAMPLES": "0", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_SAMPLES": "5", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_START_PROXY": "true", - "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_KEEP_PROXY": "true", - "DEVCONTAINER_POST_START_OBSERVE_LOCAL_PROXY_STATUS": "true", - - // Evita duplicar probes quando o manager v1.1+ já rodou com sucesso. - "DEVCONTAINER_POST_START_LEGACY_PROBES_AFTER_MANAGER": "false", - "DEVCONTAINER_POST_START_SUBSCRIPT_TIMEOUT_SECONDS": "90", - "DEVCONTAINER_ENABLE_NETWORK_CONTROL_PLANE_STATE": "true", - "DEVCONTAINER_NETWORK_CONTROL_PLANE_POST_START_ACTION": "summary", - "DEVCONTAINER_NETWORK_CONTROL_PLANE_TIMEOUT_SECONDS": "10", - "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT": "${containerWorkspaceFolder}/.devcontainer/scripts/network-control-plane-state.sh", - "DEVCONTAINER_HEALTHCHECK_MIN_NODE_MAJOR": "24", - // Safe to globalize as long as NSS_WRAPPER_* points at files that always exist. - // This keeps base container processes (including VS Code bootstrap paths) - // on a safe, always-readable NSS baseline before profile hooks refine it. - "DEVCONTAINER_NSS_WRAPPER_LIB": "/usr/local/lib/devcontainer/libnss_wrapper.so", - "LD_PRELOAD": "/usr/local/lib/devcontainer/libnss_wrapper.so", - // Bootstrap fallback must point to stable files. /tmp artifacts are runtime - // only and may not exist yet when VS Code issues early exec/user commands. - // The gatekeeper/profile path can still override these to /tmp later. - "NSS_WRAPPER_PASSWD": "/etc/passwd", - "NSS_WRAPPER_GROUP": "/etc/group", - // Canonical tooling metadata exported by the image for wrappers/checkers. - "JSONC_PARSER_MODULE_PATH": "/usr/local/share/npm-global/lib/node_modules/jsonc-parser", - // ========================================================= - // SSH VARIABLES (v5.3 - CLEANUP) - // --------------------------------------------------------- - // REMOVIDO: "DEVCONTAINER_SSH_AGENT_ALLOWED": "true" - // REMOVIDO: "SSH_AUTH_SOCK": "/ssh-agent" - // - // JUSTIFICATIVA: - // • Variáveis eram usadas com mount manual (removido) - // • VS Code native forwarding não requer essas variáveis - // • Simplificação da configuração - // ========================================================= - // Nota estrutural: - // • `containerEnv` deve complementar a imagem, não duplicar defaults - // já fixados no Dockerfile (ex.: NODE_ENV, LOG_LEVEL, LANG). - // • `FORCE_COLOR` NÃO deve ser global aqui: vários shells exportam - // `NO_COLOR`, e a combinação gera warning no startup do Node. - // • Forçar cor deve ficar em env por processo (PM2, testes, helpers). - // ------------------------------------------------------------ - "NPM_CONFIG_CACHE": "/home/node/.npm", - // ------------------------------------------------------------ - // RUNTIME TUNING (Performance & Noise) - // ------------------------------------------------------------ - // NOTA: NODE_OPTIONS removido - cada configuração de launch - // define seu próprio --max-old-space-size em runtimeArgs - // para evitar duplicação e conflitos com o debugger - "PM2_HOME": "/home/node/.pm2", - // Hint semântico (documental, não funcional) - // • Indica que há uma camada proxy entre container e browser - "PUPPETEER_ARCHITECTURE": "external-browser-via-proxy", - // Timeout de conexão CDP (milissegundos) - // • Protege contra stalls de rede - // • NÃO implica que Chrome deva estar ativo - "PUPPETEER_CONNECT_TIMEOUT": "30000", - // ------------------------------------------------------------ - // PUPPETEER & CHROME — INTEGRAÇÃO EXTERNA (CONTRATO CANÔNICO) - // ------------------------------------------------------------ - // - // MODELO FÍSICO REAL (IMPORTANTE): - // - // • Windows Host - // - Chrome REAL (backend de automação) - // - Expõe Chrome DevTools Protocol (CDP) na porta 9225 - // - Bind: 0.0.0.0 (acessível via host.docker.internal) - // - NUNCA é acessado diretamente pelo Puppeteer - // - // • DevContainer - // - Chrome Proxy Service roda AQUI (gerenciado por PM2) - // - Escuta: localhost:9224 (frontend canônico para Puppeteer) - // - Encaminha: host.docker.internal:9225 (backend para Chrome Windows) - // - Puppeteer conecta SEMPRE em localhost:9224 - // - // CONSEQUÊNCIAS DO CONTRATO: - // - // • Puppeteer conecta EXCLUSIVAMENTE em localhost:9224 - // • Proxy encaminha para host.docker.internal:9225 (Chrome no Windows) - // • Isolamento completo: Puppeteer NÃO conhece o host nem a porta 9225 - // • Chrome externo é FUNDAMENTAL para operações LLM via Puppeteer - // • A ausência de Chrome durante build/attach é ESTADO VÁLIDO - // • Chrome será iniciado sob demanda quando operações LLM forem acionadas - // • Nenhum componente de infraestrutura inicia Chrome automaticamente - // - // ------------------------------------------------------------ - "PUPPETEER_MODE": "connect", - // Puppeteer NUNCA deve baixar Chromium automaticamente - // • Chrome externo é primário - // • Chromium local é fallback técnico (imagem-level) - "PUPPETEER_SKIP_DOWNLOAD": "true", - "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD": "true", - "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium", - // ------------------------------------------------------------ - // PUPPETEER — ENDPOINT CANÔNICO (CONTRATO DE FRONTEIRA) - // ------------------------------------------------------------ - // - // TOPOLOGIA FÍSICA REAL: - // ------------------------------------------------------------ - // - // ┌─────────────────────────────────────┐ - // │ DevContainer (Docker) │ - // │ │ - // │ ┌──────────────────────────────┐ │ - // │ │ Puppeteer / Node.js │ │ - // │ │ (conecta localhost:9224) │ │ - // │ └────────────┬─────────────────┘ │ - // │ │ │ - // │ ┌────────────▼─────────────────┐ │ - // │ │ Chrome Proxy Service (PM2) │ │ - // │ │ (bind 0.0.0.0:9224) │ │ - // │ │ • Reescreve Host: headers │ │ - // │ │ • Reescreve WebSocket URLs │ │ - // │ └────────────┬─────────────────┘ │ - // │ │ host.docker.internal│ - // └───────────────┼─────────────────────┘ - // │ - // ┌───────────────▼─────────────────────┐ - // │ Windows Host │ - // │ │ - // │ ┌──────────────────────────────┐ │ - // │ │ Chrome (9225, bind 0.0.0.0) │ │ - // │ │ --remote-debugging-port=9225 │ │ - // │ └──────────────────────────────┘ │ - // │ │ - // └─────────────────────────────────────┘ - // - // VISÃO DO PUPPETEER: - // ------------------------------------------------------------ - // • Conecta SEMPRE em localhost:9224 (proxy no mesmo container) - // • NÃO conhece a porta 9225 - // • NÃO conhece o Windows Host - // • NÃO sabe que há um proxy intermediário - // - // VISÃO DO CHROME PROXY: - // ------------------------------------------------------------ - // • Escuta em 0.0.0.0:9224 (acessível dentro do container) - // • Encaminha para host.docker.internal:9225 (Chrome no Windows) - // • Reescreve URLs para tornar conexão transparente - // - // CONTRATO (INVIOLÁVEL): - // ------------------------------------------------------------ - // • Puppeteer → localhost:9224 (mesma máquina) - // • Proxy → host.docker.internal:9225 (máquina remota) - // • Violação quebra isolamento arquitetural - // ------------------------------------------------------------ - "PUPPETEER_WS_ENDPOINT": "http://localhost:9224", - // ============================================================ - // LOCALE & DOCKER ENGINE - // ============================================================ - // Variáveis de instrumentação (estabilidade de telemetria do servidor) - "VSCODE_INSTRUMENTATION": "true", - "VSCODE_SERVER_EXPECTED": "true", - "VSCODE_SERVER_ROLE": "instrumentation", - // ============================================================ - // LSP LOCAL (legado preservado, sempre desligado por padrão) - // ============================================================ - // O editor usa o TSServer/LSP nativo do TypeScript 7. O daemon MCP local - // só pode ser habilitado explicitamente em um processo isolado. - "LSP_ENABLED": "false", - "LSP_MUTATIONS_ENABLED": "false", - // Timeout por operação LSP (ms). O daemon usa este valor como fallback; - // cada chamada pode sobrescrever via options.timeoutMs. - "LSP_TOOL_TIMEOUT_MS": "15000", - // Número máximo de resultados retornados por operações de busca (references, - // workspace_symbols, diagnostics, completion). Reduzir melhora latência - // em projetos grandes; aumentar melhora completude de busca. - "LSP_MAX_RESULTS": "200" - // ============================================================ - // FILE WATCHING & VS CODE INTERNALS - // ============================================================ - // WATCHPACK_POLLING removido em v5.4.0: - // Projeto usa Vite (não webpack). Variável não tem efeito detectável no projeto. - // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-002] - }, - // ============================================================ - // VS CODE CUSTOMIZATIONS - // ============================================================ - "customizations": { - "vscode": { - "extensions": [ - "TypeScriptTeam.native-preview", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "ms-azuretools.vscode-containers", - "ms-vscode.makefile-tools", - "timonwong.shellcheck", - "redhat.vscode-yaml", - "EditorConfig.EditorConfig", - "Vue.volar", - "github.vscode-github-actions", - "DavidAnson.vscode-markdownlint" - ], - "settings": { - // TypeScript 7.0.x is GA. VS Code 1.134 still ships the legacy Node/tsserver.js client, so the external - // official LSP client remains required for now under its historical Marketplace ID `TypeScriptTeam.native-preview`. - // Its bundled server is stable TS7 (`tsc --lsp`). Remove the external client once the same client is bundled by VS Code. - // Toggle this flag to false only for a deliberate fallback to the built-in Node/tsserver.js service. - "js/ts.experimental.useTsgo": true, - // O cliente TS7 nativo é Go: esta é a chave efetiva de GOMEMLIMIT. O antigo `js/ts.tsserver.maxMemory` - // configura apenas o fallback Node/tsserver e não limita `tsc --lsp`. - "js/ts.server.goMemLimit": "1024MiB", - // O cliente LSP externo atual (ID histórico `native-preview`) tem trace LSP verboso por default; desabilitamos para não reter tráfego no - // Extension Host nem pagar serialização/logging em toda interação JS/TS. - "js/ts.trace.server": "off", - // In DevContainer, Codex must run in-container instead of being redirected to host-side WSL. - "chatgpt.runCodexInWindowsSubsystemForLinux": false, - "debug.javascript.autoAttachFilter": "onlyWithFlag", - "editor.bracketPairColorization.enabled": true, - //"editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", - "source.organizeImports": "explicit" - }, - // ======================================================== - // EDITOR & FORMATTING (controle humano explícito) - // ======================================================== - "editor.formatOnSave": true, - "editor.guides.indentation": true, - "editor.minimap.enabled": true, - "editor.minimap.maxColumn": 80, - "editor.minimap.renderCharacters": false, - "editor.renderWhitespace": "boundary", - "editor.smoothScrolling": true, - // ======================================================== - // EDITOR UX (Acessibilidade do Código) - // ======================================================== - "editor.stickyScroll.enabled": true, // Facilita navegar em classes longas do KERNEL - "eslint.alwaysShowStatus": true, - //"[javascript]": { - // "editor.defaultFormatter": "esbenp.prettier-vscode" - //}, - // ======================================================== - // ESLINT (fonte de verdade para qualidade) - // ======================================================== - "eslint.validate": ["javascript", "javascriptreact"], - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ], - "files.associations": { - "*.env*": "dotenv", - "Makefile": "makefile" - }, - "files.autoSave": "onFocusChange", - "files.insertFinalNewline": true, - "files.trimTrailingWhitespace": true, - // ======================================================== - // FILE SYSTEM & SEARCH (A Blindagem) - // ======================================================== - "files.watcherExclude": { - "**/.cache/**": true, // NOVO: Protege contra cache do Puppeteer/NPM - "**/.claude/**": true, // NOVO: Evita indexar estados pesados da IA - "**/.git/objects/**": true, - "**/.git/refs/**": true, - "**/.git/logs/**": true, - "**/backups/**": true, // v5.4.0: exclui backups do watcher - "**/artifacts/**": true, // v5.4.0: arquivos gerados - "**/monitoring/**": true, - "**/dist/**": true, - "**/logs/**": true, - "**/node_modules/**": true, - "**/node-history/**": true, // NOVO: O histórico é para o shell, não para o editor - "**/profile/**": true, - "**/respostas/**": true, - "**/tmp/**": true - }, - // ======================================================== - // EXTENSION HOST — ESTABILIDADE DE MEMÓRIA (v5.4.0) - // O extension host com 55+ extensões pode consumir 1.7+ GB. - // Reduzir auto-save frequency e desabilitar código desnecessário - // alivia o GC do extension host e reduz chances de crash/restart. - // ======================================================== - // Reduz frequência de auto-fetch Git (causa wake-ups do extension host) - "git.autofetch": false, // v5.4.0: desativado (era true) — causa I/O periódico - "git.autofetchPeriod": 1800, // v5.4.0: se reativado, 30 min mínimo - "git.confirmSync": false, - "git.decorations.enabled": true, - "git.enableSmartCommit": true, - "github.copilot.editor.enableAutoCompletions": true, - "github.copilot.editor.iterativeFixing": true, - // ======================================================== - // GITHUB COPILOT — CONFIGURAÇÕES OTIMIZADAS - // ======================================================== - "github.copilot.enable": { - "*": true, - "jsonc": true, - "markdown": true, - "plaintext": true, - "yaml": true - }, - // ======================================================== - // JAVASCRIPT / NODE.JS (infra + backend) - // ======================================================== - "javascript.preferences.importModuleSpecifier": "relative", - "javascript.updateImportsOnFileMove.enabled": "always", - "jest.autoRun": "off", - "jest.runMode": "on-demand", - "json.validate.enable": true, - // ======================================================== - // MARKDOWN & DOCUMENTATION - // ======================================================== - "markdown.preview.breaks": true, - "markdown.preview.scrollPreviewWithEditor": true, - // ======================================================== - // REFERENCES & NAVIGATION - // ======================================================== - "references.preferredLocation": "peek", - "remote.SSH.enableAgentForwarding": true, - "remote.SSH.loglevel": "info", - "remote.SSH.useLocalServer": true, - // ======================================================== - // EXTENSÕES — ESTABILIDADE DE SESSÃO - // Previne atualizações automáticas de extensões durante sessões ativas. - // Atualizações mid-session podem causar incompatibilidade de API proposals - // (ex.: chatParticipantPrivate no Copilot Chat) e reinício do extension host. - // Para atualizar extensões, faça manualmente após encerrar a sessão. - // ======================================================== - "extensions.autoUpdate": false, - "extensions.autoCheckUpdates": false, - "search.exclude": { - "**/.cache": true, - "**/.claude": true, - "**/logs": true, - "**/node_modules": true, - "**/profile": true, - "**/respostas": true - }, - // ======================================================== - // WORKSPACE TRUST & SECURITY - // ======================================================== - "security.workspace.trust.enabled": true, - "shellcheck.enable": true, - // ======================================================== - // TELEMETRY & NOISE REDUCTION - // ======================================================== - "telemetry.telemetryLevel": "off", - "terminal.integrated.copyOnSelection": true, - "terminal.integrated.cursorBlinking": true, - // ======================================================== - // TERMINAL (UX & Performance) - // Bash = canônico | PowerShell = instrumental / IA-friendly - // ======================================================== - "terminal.integrated.defaultProfile.linux": "bash", - "terminal.integrated.detectLocale": "off", - // "terminal.integrated.drawBoldTextInInvertedColors" removido: setting deprecado no VS Code 1.100+ - "terminal.integrated.enableMultiLinePasteWarning": "never", - // canvas: renderização estável em containers Linux (sem WebGL/GPU real). - // Consistente com .vscode/settings.json que também define "canvas". - "terminal.integrated.gpuAcceleration": "canvas", - "terminal.integrated.inheritEnv": true, - "terminal.integrated.profiles.linux": { - // ---------------------------------------------------- - // Bash — SHELL CANÔNICO (infra, automação, build) - // ---------------------------------------------------- - "bash": { - // "-l" (login shell): garante que /etc/profile.d/*.sh seja carregado - // em cada terminal integrado. Crítico para: - // • 10-gatekeeper-nss.sh → LD_PRELOAD com caminho absoluto - // (evita erros de linker em spawns intensivos de subprocesso) - // • 00-restore-env.sh → PATH correto com nvm/npm-global - // Sem -l, profile.d não recanonicaliza/refina NSS_WRAPPER_* nem - // reforça o LD_PRELOAD canônico para shells interativos. - "args": ["-l"], - "icon": "terminal-bash", - "path": "/bin/bash" - }, - // ---------------------------------------------------- - // PowerShell — INSTRUMENTAL / SEMÂNTICO / IA-FRIENDLY - // - // • Não é default - // • Não governa automação - // • Carrega contrato global automaticamente - // ---------------------------------------------------- - "pwsh": { - "args": ["-NoLogo"], - "icon": "terminal-powershell", - "path": "/usr/bin/pwsh" - } - }, - // -------------------------------------------------------- - // COMPORTAMENTO GERAL DO TERMINAL - // -------------------------------------------------------- - // ============================================================ - // TERMINAL — OPTIMIZED SETTINGS (v5.2) - // ============================================================ - // Scrollback buffer otimizado para ambiente DevContainer: - // • 10,000 linhas = ~2MB memória (scrollback típico) - // • Suficiente para debug sessions (tail -f logs, PM2, etc) - // • Redução de 50% vs. v3.4 (20,000 → 10,000) - // • Previne memory bloat em long-running containers - // - // Context: Em DevContainers, scrollback persiste enquanto o - // terminal está aberto. Com 4-5 terminais simultâneos, - // 20,000 linhas/terminal = ~40MB overhead (10,000 = ~20MB). - // - // Se precisar de mais: - // • Use 'make logs-follow' (arquivos em logs/) - // • Configure PM2 logs (ecosystem.config.js) - // • Use Docker logs (docker logs -f ) - // ============================================================ - "terminal.integrated.scrollback": 10000, - // ======================================================== - // PRETTIER (somente com configuração explícita) - // ======================================================== - // "prettier.requireConfig": true, - // ======================================================== - // TESTING (Jest + Test Explorer) - // ======================================================== - "testExplorer.useNativeTesting": false, - // ======================================================== - // YAML / JSON / SHELL (infra, contratos, scripts) - // ======================================================== - "yaml.validate": true - } - } - }, - // ============================================================ - // FEATURES - // ============================================================ - // Deliberadamente omitidas. O bloco vazio `"features": {}` faz o Dev Containers - // gerar um Dockerfile intermediário desnecessário e pode disparar warnings do - // builder sobre `ARG BASE_IMAGE` sem default. O tooling é instalado manualmente - // no Dockerfile para manter controle e evitar duplicações. - // =========================================================== - // PORT FORWARDING — CONTRATO FINAL (DENY BY DEFAULT) - // =========================================================== - // - // Este bloco NÃO é apenas configuração. - // Ele é um DOCUMENTO ARQUITETURAL VIVO. - // - // --------------------------------------------------------------------------- - // PRINCÍPIO FUNDAMENTAL - // --------------------------------------------------------------------------- - // • Política global: DENY-BY-DEFAULT - // • Nenhuma porta é exposta por acidente - // • Nenhuma inferência automática é aceitável - // • Toda porta exposta deve ter: - // - Justificativa funcional - // - Público-alvo explícito - // - Comportamento de forward declarado - // - // --------------------------------------------------------------------------- - // TOPOLOGIA FÍSICA (3 CAMADAS) - // --------------------------------------------------------------------------- - // - // WINDOWS HOST (Máquina Física) - // ├── Chrome Windows (chrome.exe, porta 9225) - // │ • Inicia: START-CHROME-SIMPLE.bat - // │ • Função: LLM Automation (ChatGPT, Gemini via Puppeteer) - // │ • Protocolo: Chrome DevTools Protocol (CDP) - // │ • Ontologia: Windows gerencia, Container conecta - // │ - // └── WSL2 (Virtual Machine Linux) - // └── Docker Desktop (Container Engine) - // └── DevContainer (Node.js Runtime) - // └── PM2 (Process Manager - 3 processos): - // │ - // ├── agente-gpt (Main Process) - // │ • Script: index.js → src/main.js - // │ • Função: Kernel + Drivers + Orchestration + NERV - // │ • Porta: NENHUMA (IPC via NERV) - // │ • Depende: Chrome Proxy (9224) - // │ - // ├── dashboard-web (Server Process) - // │ • Script: src/server/main.js - // │ • Função: HTTP + Socket.io + API REST + Telemetry - // │ • Porta: 3008 (HTTP + WebSocket) - // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) - // │ • Dependências: ZERO (autônomo) - // │ - // └── chrome-proxy (Proxy Service) - // • Script: scripts/chrome-proxy-service.js - // • Função: Transparent Proxy (HTTP + WebSocket) - // • Porta IN: 9224 (container) - // • Porta OUT: 9225 (host Windows via host.docker.internal) - // • Fluxo: Puppeteer → localhost:9224 → Chrome (9225) - // - // --------------------------------------------------------------------------- - // ARQUITETURA POR PLANOS FUNCIONAIS - // --------------------------------------------------------------------------- - // - // ┌──────────────────────────────────────────────────────────┐ - // │ UI HUMANA (Interfaces interativas) │ - // │ │ - // │ 3008 → Dashboard Principal (Mission Control) │ - // │ • Processo: dashboard-web (PM2) │ - // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) │ - // │ • HTTP + Socket.io + API REST │ - // │ • Dependências: ZERO (autônomo) │ - // │ │ - // │ • Auto-forward com notificação │ - // └──────────────────────────────────────────────────────────┘ - // - // ┌──────────────────────────────────────────────────────────┐ - // │ INFRAESTRUTURA (Fronteiras Arquiteturais) │ - // │ │ - // │ 9224 → Chrome Proxy Service (Container → Windows) │ - // │ • Processo: chrome-proxy (PM2) │ - // │ • Função: Proxy transparente HTTP + WebSocket │ - // │ • IN: localhost:9224 (Puppeteer conecta aqui) │ - // │ • OUT: host.docker.internal:9225 (Chrome) │ - // │ │ - // │ 9225 → Chrome Real (Windows Host) │ - // │ • Processo: chrome.exe (Windows) │ - // │ • Inicia: START-CHROME-SIMPLE.bat │ - // │ • Função: Chrome DevTools Protocol (CDP) │ - // │ • Acesso: NUNCA direto, sempre via proxy 9224 │ - // │ │ - // │ • NÃO são UI humana │ - // │ • NÃO sofrem auto-forward │ - // │ • Representam FRONTEIRAS LÓGICAS │ - // └──────────────────────────────────────────────────────────┘ - // - // ┌──────────────────────────────────────────────────────────┐ - // │ DEBUG (OPT-IN — NUNCA NOTIFICA POR PADRÃO) │ - // │ │ - // │ 9229 → Node.js Debug (Primary - agente-gpt) │ - // │ 9230 → Node.js Debug (Fallback - dashboard-web) │ - // │ │ - // │ • Exposição silenciosa │ - // │ • Uso deliberado (--inspect flag) │ - // │ • Não notifica o operador │ - // └──────────────────────────────────────────────────────────┘ - // - // --------------------------------------------------------------------------- - // NOTAS NORMATIVAS IMPORTANTES - // --------------------------------------------------------------------------- - // - // PM2 (Process Manager): - // • Organizador geral de processos Node.js - // • Gerencia 3 processos: agente-gpt, dashboard-web, chrome-proxy - // • NÃO tem porta própria (daemon interno) - // • Web dashboard opcional (porta 9615) NÃO configurado por padrão - // - // Chrome Dual Purpose (2 usos INDEPENDENTES): - // 1. Dashboard (porta 3008): - // • Humano → ANY Browser → http://localhost:3008 → dashboard-web - // • ZERO dependência de Puppeteer - // • ZERO dependência de Chrome Windows (porta 9225) - // - // 2. LLM Automation (porta 9225): - // • agente-gpt → Puppeteer → localhost:9224 → proxy → Chrome (9225) - // • Puppeteer required - // • Chrome Windows required - // - // Fluxo de Dados LLM Automation: - // Puppeteer → localhost:9224 (proxy) → host.docker.internal:9225 (Chrome) - // - // O sistema possui Chromium local como fallback técnico de imagem, mas o fluxo - // normal de automação LLM exige Chrome externo via Chrome Proxy. O fallback local - // só deve ser ativado por decisão explícita do runtime. - // - // =========================================================== - // ============================================================================ - // PORT POLICY — SCOPE & NON-GOALS (CRITICAL) - // ---------------------------------------------------------------------------- - // Este DevContainer governa APENAS portas abertas pelo próprio container. - // - // Portas pertencentes ao HOST (Windows) — ex.: Chrome DevTools (9225) — - // estão FORA do escopo técnico deste arquivo e: - // - // • NÃO podem ser forwardadas pelo VS Code - // • NÃO devem ser declaradas em forwardPorts - // • NÃO devem aparecer em portsAttributes - // • NÃO devem ser inferidas ou "protegidas" aqui - // - // A porta 9225 (Chrome Real, Windows Host): - // • É deliberadamente EXCLUÍDA deste contrato - // • Só é acessível via Chrome Proxy Service (9224) - // • Qualquer tentativa de acesso direto é violação arquitetural - // ============================================================================ - "forwardPorts": [ - 3008, // Dashboard Principal — Mission Control (HTTP + Socket.io + API) - 5173, // Vite Dev Server — Vue Dashboard (dev only) - 9224, // Chrome Proxy Service (Container → Windows Host) - 9229, // Node.js Debug — Primary (agente-gpt --inspect) - 9230 // Node.js Debug — Fallback (dashboard-web --inspect) - ], - // ============================================================ - // RESOURCE LIMITS (Proteção do Host) - // ============================================================ - "hostRequirements": { - "cpus": 4, - "memory": "8gb", - "storage": "40gb" - }, - "mounts": [ - // ============================================================================ - // NOTA NORMATIVA — MOUNTS VS VARIÁVEIS (CONTRATO DE FASE) - // ---------------------------------------------------------------------------- - // Esta seção opera no PLANO INFRAESTRUTURAL do Docker. - // - // Regras não negociáveis: - // • O Docker resolve mounts ANTES da inicialização do container - // • O campo `target=` (container side) DEVE ser caminho literal - // - Docker NÃO expande variáveis (ex.: ${containerUserHome}, $USER) - // - Docker processamento ocorre ANTES do container existir - // • O campo `source=` (host side) PODE usar variáveis VS Code - // - VS Code expande ${localEnv:*}, ${localWorkspaceFolder} ANTES de chamar docker - // - Exemplo válido: "source=${localEnv:HOME}/data,target=/data,type=bind" - // - // Implicação arquitetural: - // • `mounts` materializam decisões já tomadas (infraestrutura concreta) - // • `containerEnv` expressa semântica dinâmica (comportamento do sistema) - // - // Portanto: - // • ✅ VÁLIDO: source=${localWorkspaceFolder}/data,target=/app/data - // • ❌ INVÁLIDO: source=/data,target=${containerWorkspaceFolder}/data - // • Usar caminhos literais em `target=` coerentes com `remoteUser: "node"` - // • Centralizar abstrações dinâmicas exclusivamente em `containerEnv` - // - // Violação deste contrato resulta em erro fatal no `docker run`. - // Referência: Docker CLI docs (--mount flag), VS Code DevContainers Variables - // ============================================================================ - // ===================================================================== - // CORE — CACHE & PERFORMANCE (XDG_CACHE_HOME) - // --------------------------------------------------------------------- - // • Cache genérico de ferramentas, linguagens e utilitários - // • Acelera rebuilds e reduz reindexação - // • Seguro para purge eventual - // ===================================================================== - "source=devcontainer-cache,target=/home/node/.cache,type=volume", - // Cache específico e pesado do Puppeteer (isolado por motivo técnico) - // • Evita downloads repetidos - // • Não deve ser compartilhado com outros caches - "source=devcontainer-puppeteer-cache,target=/home/node/.cache/puppeteer,type=volume", - // Cache dedicado do TypeScript / JavaScript Language Server - // • Melhora IntelliSense e Copilot em projetos grandes - // • Reduz reindexação após rebuild - "source=devcontainer-ts-cache,target=/home/node/.cache/typescript,type=volume", - // ===================================================================== - // NODE / PACKAGE ECOSYSTEM - // --------------------------------------------------------------------- - // • Persistência correta do ecossistema Node - // • NÃO persiste node_modules (por design) - // ===================================================================== - // npm cache (respeita NPM_CONFIG_CACHE) - "source=devcontainer-npm-cache,target=/home/node/.npm,type=volume", - // npm-global (bins globais, se utilizados) - "source=devcontainer-npm-global,target=/home/node/.npm-global,type=volume", - // ===================================================================== - // PROCESS & RUNTIME STATE - // --------------------------------------------------------------------- - // • Estado de processos gerenciados (PM2) - // • Logs, dumps e PIDs - // ===================================================================== - "source=devcontainer-pm2-state,target=/home/node/.pm2,type=volume", - // ===================================================================== - // USER CONFIGURATION (XDG — CONFIG / SHARE / STATE) - // --------------------------------------------------------------------- - // • Fonte de verdade para preferências do usuário - // • Inclui GitHub Copilot, Copilot Chat e extensões - // ===================================================================== - // XDG_CONFIG_HOME - "source=devcontainer-user-config,target=/home/node/.config,type=volume", - // XDG_DATA_HOME - "source=devcontainer-local-share,target=/home/node/.local/share,type=volume", - // XDG_STATE_HOME - // • Estado transitório de ferramentas, incluindo Copilot Chat - "source=devcontainer-local-state,target=/home/node/.local/state,type=volume", - // ===================================================================== - // AI / AGENT STATE - // --------------------------------------------------------------------- - // • Estado persistente de agentes locais (Claude, etc.) - // • NÃO inclui memória semântica remota (server-side) - // ===================================================================== - "source=devcontainer-claude-state,target=/home/node/.claude,type=volume", - // ===================================================================== - // IDENTITY & SECRETS (STRICT, NÃO COPIAR) - // --------------------------------------------------------------------- - // • Chaves NUNCA são persistidas como volume - // • Apenas forwarding seguro via agent - // ===================================================================== - // ========================================================= - // SSH AGENT FORWARDING — VS CODE NATIVE (v5.3+) - // --------------------------------------------------------- - // HISTÓRICO: - // v5.2: Mount manual "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent" - // v5.3: REMOVIDO - Causava erro fatal de type mismatch - // - // ERRO ANTERIOR: - // error mounting ... to rootfs at "/ssh-agent": not a directory: - // Are you trying to mount a directory onto a file (or vice-versa)? - // - // CAUSA: - // SSH_AUTH_SOCK é um socket UNIX (arquivo especial) - // Docker tentava montar como diretório - // Type mismatch → container não iniciava - // - // SOLUÇÃO: - // VS Code Remote Containers fornece SSH forwarding NATIVO - // Não requer mount manual - // Funciona automaticamente quando SSH agent está presente no host - // - // BENEFÍCIOS: - // ✅ Container inicia com ou sem SSH agent - // ✅ Zero configuração manual - // ✅ Fail-safe por design - // ✅ Cross-platform (Windows/Linux/macOS) - // - // VALIDAÇÃO: - // Dentro do container: - // $ echo $SSH_AUTH_SOCK # Deve mostrar path se agent disponível - // $ ssh-add -l # Lista chaves (se agent presente) - // $ ssh -T git@github.com # Testa autenticação GitHub - // ========================================================= - // GnuPG keyring (persistente, mas isolado) - "source=devcontainer-gpg-cache,target=/home/node/.gnupg,type=volume", - // ===================================================================== - // VS CODE SERVER (PERFORMANCE CRÍTICO) - // --------------------------------------------------------------------- - // • Binário do VS Code Server - // • Extensões (incluindo Copilot) - // • Estado de autenticação e UX - // - // ⚠️ Volume sensível — purge apenas se necessário. - // Geração TS7 v1: inicia o servidor remoto sem extensões herdadas do volume pré-migração. - // O volume `devcontainer-vscode-server` anterior permanece intacto para rollback/forense. - // ===================================================================== - "source=devcontainer-vscode-server-ts7-v1,target=/home/node/.vscode-server,type=volume", - // ===================================================================== - // UX — HISTÓRICO DE SHELL (FORA DO $HOME POR DESIGN) - // --------------------------------------------------------------------- - // • Histórico persistente entre rebuilds - // • Não polui o $HOME - // ===================================================================== - "source=devcontainer-bash-history,target=/home/node-history,type=volume", - // ===================================================================== - // INFRASTRUCTURE (DEV ONLY — ALTO PRIVILÉGIO) - // --------------------------------------------------------------------- - // • Docker CLI usa o socket do host - // • Equivale a root no host - // • NUNCA usar em produção - // ===================================================================== - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" - ], - "name": "ChatGPT Docker Puppeteer - Dev Container", - // Default oficial para portas não declaradas explicitamente. - // Use otherPortsAttributes em vez de wildcard "*" dentro de portsAttributes. - "otherPortsAttributes": { - "onAutoForward": "ignore" - }, - "portsAttributes": { - // ---------------------------------------------------------- - // Política global: DENY-BY-DEFAULT - // ---------------------------------------------------------- - // • Qualquer porta NÃO declarada explicitamente é ignorada - // • Nenhuma inferência automática é permitida - // ---------------------------------------------------------- - // ================== UI HUMANA ============================== - "3008": { - "label": "Dashboard Principal — Mission Control (HTTP + Socket.io + API)", - "onAutoForward": "notify", - "protocol": "http", - "webRoot": "${containerWorkspaceFolder}/src/server" - }, - "5173": { - "label": "Vite Dev Server — Vue Dashboard (dev only)", - "onAutoForward": "notify", - "protocol": "http", - "webRoot": "${containerWorkspaceFolder}/src/dashboard-ui" - // NOTA: HMR (Hot Module Reload) configurado no vite.config.js - // - port: 5173 (HTTP + WebSocket na MESMA porta) - // - hmr.clientPort: 5173 (WebSocket fixado) - // - hmr.host: 'localhost' (Windows acesso via port forwarding) - // - watch.usePolling: true (Docker volumes file watching) - // ✅ WebSocket HMR usa a MESMA porta 5173 (não precisa forward adicional) - }, - // ================== INFRAESTRUTURA ========================= - "9224": { - "label": "Chrome Proxy Service (Container → Windows Host)", - "onAutoForward": "ignore", - "protocol": "http" - // NOTA ARQUITETURAL: - // • Esta porta ESTÁ em forwardPorts (sempre disponível) - // • onAutoForward: "ignore" = não notifica o usuário (porta interna) - // • Reconciliação conceitual: forward != auto-forward - // - forwardPorts: política de DISPONIBILIDADE (sempre expor) - // - onAutoForward: política de UX (notificar humano ou não) - // - // FUNÇÃO: - // • Proxy transparente HTTP + WebSocket (CDP) - // • Puppeteer → localhost:9224 → host.docker.internal:9225 - // • ÚNICA fronteira autorizada para Chrome externo - // • Porta 9225 (Chrome Real, Windows) NUNCA acessada diretamente - }, - // ================== DEBUG ================================== - "9229": { - "label": "Node.js Debug — Primary (agente-gpt)", - "onAutoForward": "silent", - "protocol": "http" - // NOTA: Node Inspector expõe endpoints HTTP (/json/list, /json/version) - // Não é HTTP REST típico, mas protocol: "http" evita interpretações erradas - // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9229 - }, - "9230": { - "label": "Node.js Debug — Fallback (dashboard-web)", - "onAutoForward": "silent", - "protocol": "http" - // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9230 - } - }, - // ============================================================ - // LIFECYCLE HOOKS - Sincronia de Inicialização - // ============================================================ - // Chamamos bash explicitamente dentro do login shell. - // Motivo: - // bash -lc ' + + +``` + +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/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/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/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..b224963 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "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 ." + }, + "dependencies": { + "ajv": "^8.20.0", + "jsonc-parser": "^3.3.1", + "yaml": "^2.9.0" + } +} 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/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..213ec32 --- /dev/null +++ b/src/discovery/discovery.test.ts @@ -0,0 +1,70 @@ +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 { 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; +} + +test.each([ + [ + "prefers .devcontainer/devcontainer.json over every other location", + ".devcontainer/devcontainer.json", + ], + ["falls back to .devcontainer.json before subfolders", ".devcontainer.json"], +])("%s", (_, winner) => { + const all = [ + ".devcontainer/devcontainer.json", + ".devcontainer.json", + ".devcontainer/a/devcontainer.json", + ]; + const root = project(...all.slice(all.indexOf(winner))); + expect(discoverDevcontainer(root)).toEqual([join(root, winner)]); +}); + +test("returns every one-level subfolder config, sorted, and ignores deeper ones", () => { + const root = project( + ".devcontainer/b/devcontainer.json", + ".devcontainer/a/devcontainer.json", + ".devcontainer/c/deep/devcontainer.json", + ); + expect(discoverDevcontainer(root)).toEqual([ + join(root, ".devcontainer/a/devcontainer.json"), + join(root, ".devcontainer/b/devcontainer.json"), + ]); +}); + +test("skips a directory named devcontainer.json", () => { + const root = project(".devcontainer/devcontainer.json/placeholder", ".devcontainer.json"); + expect(discoverDevcontainer(root)).toEqual([join(root, ".devcontainer.json")]); +}); + +test("returns a file target as-is", () => { + const root = project("configs/custom.json"); + expect(discoverDevcontainer(join(root, "configs/custom.json"))).toEqual([ + join(root, "configs/custom.json"), + ]); +}); + +test("returns an empty array when nothing is found", () => { + const root = project("README.md"); + expect(discoverDevcontainer(root)).toEqual([]); + expect(discoverDevcontainer(join(root, "missing"))).toEqual([]); +}); + +test("treats a .devcontainer file as absent rather than throwing", () => { + const root = project(".devcontainer"); + expect(discoverDevcontainer(root)).toEqual([]); +}); diff --git a/src/discovery/discovery.ts b/src/discovery/discovery.ts new file mode 100644 index 0000000..bf8e469 --- /dev/null +++ b/src/discovery/discovery.ts @@ -0,0 +1,51 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Resolves a `dcx check` target to the devcontainer.json file(s) to lint, in the + * spec's discovery order (DESIGN §3.1, §7.2). + * + * 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 array + * into exit 2 (DESIGN §7.4). I/O errors other than a missing path still throw. + * + * @param target File or directory, absolute or relative to the working directory. + * @returns Absolute paths, sorted; empty when no config is found. + */ +export function discoverDevcontainer(target = process.cwd()): string[] { + const root = resolve(target); + const stats = statSync(root, { throwIfNoEntry: false }); + if (!stats) return []; + if (stats.isFile()) return [root]; + + const primary = [ + join(root, ".devcontainer", "devcontainer.json"), + join(root, ".devcontainer.json"), + ].find(isFile); + if (primary) return [primary]; + + const dir = join(root, ".devcontainer"); + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return []; + return readdirSync(dir) + .map((name) => join(dir, name, "devcontainer.json")) + .filter(isFile) + .sort(); +} + +/** + * True only for a regular file (following symlinks); a directory named + * `devcontainer.json`, or a path whose parent is a file, is not one. + */ +function isFile(path: string): boolean { + try { + return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; + } catch (err) { + // throwIfNoEntry only covers ENOENT; a file where a parent directory should be raises ENOTDIR. + if ((err as NodeJS.ErrnoException).code === "ENOTDIR") return false; + throw err; + } +} 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/vfs/bunfs.ts b/src/vfs/bunfs.ts new file mode 100644 index 0000000..0fdd3ce --- /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 (DESIGN §5.3). `src/vfs` is one of + * the few places core code may call Bun or `node:fs` directly (DESIGN §14, + * decision 10). + */ +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..75a4848 --- /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 (DESIGN §5.3, §9.2). 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..bb6c7a5 --- /dev/null +++ b/src/vfs/vfs.test.ts @@ -0,0 +1,185 @@ +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. +const canDenyAccess = 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..0298114 --- /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 (DESIGN §4.2 invariant 2, §5.3). + * 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/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 + } +} From 29f6a693efd62a1213fda9db134b4b5ba096c652 Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Tue, 15 Sep 2026 12:25:45 -0600 Subject: [PATCH 3/9] discovery uses 'vfs' --- src/discovery/discovery.test.ts | 48 +++++++++++++++++++++-------- src/discovery/discovery.ts | 53 ++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/discovery/discovery.test.ts b/src/discovery/discovery.test.ts index 213ec32..d495282 100644 --- a/src/discovery/discovery.test.ts +++ b/src/discovery/discovery.test.ts @@ -2,6 +2,8 @@ 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[] = []; @@ -18,53 +20,73 @@ function project(...files: string[]): string { 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", (_, winner) => { +])("%s", async (_, winner) => { const all = [ ".devcontainer/devcontainer.json", ".devcontainer.json", ".devcontainer/a/devcontainer.json", ]; const root = project(...all.slice(all.indexOf(winner))); - expect(discoverDevcontainer(root)).toEqual([join(root, winner)]); + expect(await discoverDevcontainer(disk, root)).toEqual([join(root, winner)]); }); -test("returns every one-level subfolder config, sorted, and ignores deeper ones", () => { +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(discoverDevcontainer(root)).toEqual([ + expect(await discoverDevcontainer(disk, root)).toEqual([ join(root, ".devcontainer/a/devcontainer.json"), join(root, ".devcontainer/b/devcontainer.json"), ]); }); -test("skips a directory named devcontainer.json", () => { +test("skips a directory named devcontainer.json", async () => { const root = project(".devcontainer/devcontainer.json/placeholder", ".devcontainer.json"); - expect(discoverDevcontainer(root)).toEqual([join(root, ".devcontainer.json")]); + expect(await discoverDevcontainer(disk, root)).toEqual([join(root, ".devcontainer.json")]); }); -test("returns a file target as-is", () => { +test("returns a file target as-is", async () => { const root = project("configs/custom.json"); - expect(discoverDevcontainer(join(root, "configs/custom.json"))).toEqual([ + expect(await discoverDevcontainer(disk, join(root, "configs/custom.json"))).toEqual([ join(root, "configs/custom.json"), ]); }); -test("returns an empty array when nothing is found", () => { +test("returns an empty array when nothing is found", async () => { const root = project("README.md"); - expect(discoverDevcontainer(root)).toEqual([]); - expect(discoverDevcontainer(join(root, "missing"))).toEqual([]); + expect(await discoverDevcontainer(disk, root)).toEqual([]); + expect(await discoverDevcontainer(disk, join(root, "missing"))).toEqual([]); }); -test("treats a .devcontainer file as absent rather than throwing", () => { +test("treats a .devcontainer file as absent rather than throwing", async () => { const root = project(".devcontainer"); - expect(discoverDevcontainer(root)).toEqual([]); + expect(await discoverDevcontainer(disk, root)).toEqual([]); +}); + +// 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)).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)).toEqual([]); }); diff --git a/src/discovery/discovery.ts b/src/discovery/discovery.ts index bf8e469..554ad81 100644 --- a/src/discovery/discovery.ts +++ b/src/discovery/discovery.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; +import type { FileSystem } from "../vfs/vfs"; /** * Resolves a `dcx check` target to the devcontainer.json file(s) to lint, in the @@ -13,39 +13,52 @@ import { join, resolve } from "node:path"; * Absence is a normal result, not an exception: the CLI turns an empty array * into exit 2 (DESIGN §7.4). 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 (DESIGN §4.2 invariant 2, §5.3). * @param target File or directory, absolute or relative to the working directory. * @returns Absolute paths, sorted; empty when no config is found. */ -export function discoverDevcontainer(target = process.cwd()): string[] { +export async function discoverDevcontainer( + fs: FileSystem, + target = process.cwd(), +): Promise { const root = resolve(target); - const stats = statSync(root, { throwIfNoEntry: false }); + const stats = await fs.stat(root); if (!stats) return []; - if (stats.isFile()) return [root]; + if (stats.kind === "file") return [root]; - const primary = [ + // 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"), - ].find(isFile); - if (primary) return [primary]; + ]) { + if (await isFile(fs, candidate)) return [candidate]; + } const dir = join(root, ".devcontainer"); - if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return []; - return readdirSync(dir) - .map((name) => join(dir, name, "devcontainer.json")) - .filter(isFile) - .sort(); + if ((await fs.stat(dir))?.kind !== "directory") return []; + + // readDir lists in codepoint order and neither step below reorders, so the result is sorted. + 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"); + return (await isFile(fs, candidate)) ? candidate : undefined; + }), + ); + + return found.filter((candidate) => candidate !== undefined); } /** * 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. */ -function isFile(path: string): boolean { - try { - return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; - } catch (err) { - // throwIfNoEntry only covers ENOENT; a file where a parent directory should be raises ENOTDIR. - if ((err as NodeJS.ErrnoException).code === "ENOTDIR") return false; - throw err; - } +async function isFile(fs: FileSystem, path: string): Promise { + return (await fs.stat(path))?.kind === "file"; } From 273b564e6ab3878bc873525c48c62a828a4d76bd Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Wed, 23 Sep 2026 10:38:04 -0600 Subject: [PATCH 4/9] added AJV validators via generator script --- scripts/build-validators.ts | 64 ++++++++++++++++++ src/discovery/discovery.test.ts | 66 +++++++++++++++---- src/discovery/discovery.ts | 39 ++++++++--- .../generated/devContainer.base.validator.js | 1 + .../devContainerFeature.validator.js | 1 + src/schema/schemas.test.ts | 21 ++++++ src/schema/schemas.ts | 20 ++++++ 7 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 scripts/build-validators.ts create mode 100644 src/schema/generated/devContainer.base.validator.js create mode 100644 src/schema/generated/devContainerFeature.validator.js create mode 100644 src/schema/schemas.test.ts create mode 100644 src/schema/schemas.ts diff --git a/scripts/build-validators.ts b/scripts/build-validators.ts new file mode 100644 index 0000000..1a4cb21 --- /dev/null +++ b/scripts/build-validators.ts @@ -0,0 +1,64 @@ +#!/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 + * (DCL-10). Output is committed — it's a build-time input, not a runtime artefact. + * + * 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(DCL-10): both schemas use `format: "uri"` — confirmed by the + // `unknown format "uri" ignored` warning Ajv prints at compile time, since + // `ajv-formats` isn't installed. Format keywords are currently no-ops. Decide + // whether to add `ajv-formats` (see the work item's implementation notes) 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/discovery/discovery.test.ts b/src/discovery/discovery.test.ts index d495282..378a86c 100644 --- a/src/discovery/discovery.test.ts +++ b/src/discovery/discovery.test.ts @@ -35,7 +35,7 @@ test.each([ ".devcontainer/a/devcontainer.json", ]; const root = project(...all.slice(all.indexOf(winner))); - expect(await discoverDevcontainer(disk, root)).toEqual([join(root, winner)]); + expect((await discoverDevcontainer(disk, root)).targets).toEqual([join(root, winner)]); }); test("returns every one-level subfolder config, sorted, and ignores deeper ones", async () => { @@ -44,33 +44,75 @@ test("returns every one-level subfolder config, sorted, and ignores deeper ones" ".devcontainer/a/devcontainer.json", ".devcontainer/c/deep/devcontainer.json", ); - expect(await discoverDevcontainer(disk, root)).toEqual([ + 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)).toEqual([join(root, ".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"); - expect(await discoverDevcontainer(disk, join(root, "configs/custom.json"))).toEqual([ - join(root, "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 an empty array when nothing is found", async () => { +test("returns no targets when nothing is found, and names the target itself when it doesn't exist", async () => { const root = project("README.md"); - expect(await discoverDevcontainer(disk, root)).toEqual([]); - expect(await discoverDevcontainer(disk, join(root, "missing"))).toEqual([]); + 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"); - expect(await discoverDevcontainer(disk, root)).toEqual([]); + 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 @@ -80,7 +122,7 @@ test("finds a config that exists only as an overlay buffer", async () => { const overlay = new OverlayFS(disk); const path = join(root, ".devcontainer", "devcontainer.json"); overlay.set(path, "{}"); - expect(await discoverDevcontainer(overlay, root)).toEqual([path]); + expect((await discoverDevcontainer(overlay, root)).targets).toEqual([path]); }); test("a buffer does not resurrect a config deleted from disk", async () => { @@ -88,5 +130,5 @@ test("a buffer does not resurrect a config deleted from disk", async () => { const overlay = new OverlayFS(disk); await overlay.readFile(join(root, ".devcontainer", "devcontainer.json")); rmSync(join(root, ".devcontainer", "devcontainer.json")); - expect(await discoverDevcontainer(overlay, root)).toEqual([]); + expect((await discoverDevcontainer(overlay, root)).targets).toEqual([]); }); diff --git a/src/discovery/discovery.ts b/src/discovery/discovery.ts index 554ad81..a497539 100644 --- a/src/discovery/discovery.ts +++ b/src/discovery/discovery.ts @@ -1,6 +1,18 @@ 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 exit-2 message ([[DCL-26 CLI flags, wiring and exit codes]]) + * 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 * spec's discovery order (DESIGN §3.1, §7.2). @@ -10,45 +22,54 @@ import type { FileSystem } from "../vfs/vfs"; * 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 array - * into exit 2 (DESIGN §7.4). I/O errors other than a missing path still throw. + * 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 (DESIGN §7.4). 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 (DESIGN §4.2 invariant 2, §5.3). * @param target File or directory, absolute or relative to the working directory. - * @returns Absolute paths, sorted; empty when no config is found. + * @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 { +): Promise { const root = resolve(target); const stats = await fs.stat(root); - if (!stats) return []; - if (stats.kind === "file") return [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"), ]) { - if (await isFile(fs, candidate)) return [candidate]; + 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 []; + 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 found.filter((candidate) => candidate !== undefined); + return { targets: found.filter((candidate) => candidate !== undefined), searched }; } /** 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 {}); +test.todo( + "provenance() has one entry per vendored schema, each with a url, commit and date", + () => {}, +); +test.todo( + "vendored files match the submodule's copies (skipped when the submodule is absent)", + () => {}, +); +test.todo("offline determinism — same input, identical output, no network reachable", () => {}); +test.todo( + "unevaluatedProperties is genuinely enforced by the generated validator " + + "(catches importing the wrong Ajv entrypoint — see DCL-10's implementation notes)", + () => {}, +); diff --git a/src/schema/schemas.ts b/src/schema/schemas.ts new file mode 100644 index 0000000..decbd15 --- /dev/null +++ b/src/schema/schemas.ts @@ -0,0 +1,20 @@ +import { validate } from "./generated/devContainer.base.validator"; + +/** + * Per-schema provenance: where each vendored file in `schemas/` came from and when it + * was last checked against the `devcontainer-spec` submodule (DCL-10). + * + * TODO(DCL-10): read from `schemas/provenance.json`, not written yet. + */ +export interface Provenance { + readonly url: string; + readonly commit: string; + readonly retrieved: string; +} + +/** + * TODO(DCL-10): not implemented. One entry per vendored schema, keyed by filename. + */ +export function provenance(): Record { + throw new Error("not implemented"); +} From 5c65074bdc6710a3588441027db4add5b3b1342c Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Wed, 23 Sep 2026 14:39:37 -0600 Subject: [PATCH 5/9] updated CI, added submodule drift check --- .github/workflows/ci.yaml | 132 +-- .github/workflows/schema-drift.yaml | 75 ++ bunfig.toml | 3 + docs/DESIGN.md | 1455 --------------------------- package.json | 3 +- src/schema/provenance.json | 17 + src/schema/schemas.test.ts | 97 +- src/schema/schemas.ts | 16 +- 8 files changed, 207 insertions(+), 1591 deletions(-) create mode 100644 .github/workflows/schema-drift.yaml create mode 100644 bunfig.toml delete mode 100644 docs/DESIGN.md create mode 100644 src/schema/provenance.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4df073e..9b87a3c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,25 +27,19 @@ jobs: 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} + - { 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 + - uses: oven-sh/setup-bun@v2 - name: Build - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: go build ./... + run: bun ci + run: bun run 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 @@ -62,111 +56,23 @@ jobs: fail-fast: false matrix: include: - - {os: ubuntu-latest, race: true} - - {os: macos-latest, race: false} - - {os: windows-latest, race: false} + - { os: ubuntu-latest, race: true } + - { os: macos-latest, race: false } + - { os: windows-latest, race: false } steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Vet - run: go vet ./... + - name: Format + run: bun run format:check - name: Test if: ${{ !matrix.race }} - run: go test ./... + run: bun test - - name: Test with race detector and coverage - if: ${{ matrix.race }} - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - - # 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" - - - name: Upload coverage profile - if: ${{ matrix.race }} - uses: actions/upload-artifact@v4 - with: - name: coverage-profile - path: coverage.out - if-no-files-found: error - - # 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 - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Fuzz - run: make fuzz FUZZTIME=60s - - - name: Upload crash reproducers - if: failure() - uses: actions/upload-artifact@v4 - with: - name: fuzz-crashers - path: "**/testdata/fuzz/**" - if-no-files-found: ignore - - # 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 - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Check formatting - run: make fmt-check - - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: codecov/codecov-action@v3 with: - version: v2.13.2 + file: ./coverage/lcov.info + fail_ci_if_error: true 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/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/docs/DESIGN.md b/docs/DESIGN.md deleted file mode 100644 index 69b5aa2..0000000 --- a/docs/DESIGN.md +++ /dev/null @@ -1,1455 +0,0 @@ -# dcx — Design Document - -**Status:** Draft for review -**Date:** 2026-09-10 -**Language:** TypeScript on Bun (see [Language Decision](#2-language-decision)) - ---- - -## 1. Overview - -`dcx` is a standalone 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** (`src/…`) | Reusable TypeScript modules: parse → model → analyze → diagnose | This doc | -| **CLI** (`src/cli`) | `dcx check` — for terminals, CI, pre-commit | This doc | -| **LSP server** (`src/server`) | `dcx serve` — the same package, over stdio | Designed for, built later | -| **VSCode extension** (`extensions/vscode`) | Imports the server in-process | 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 npm package with no runtime dependency beyond Bun or - Node, and no native addons. Self-contained executables are available for - environments without a JavaScript runtime. -- **G3** — Be architecturally ready for an LSP from day one: no global state, no - direct filesystem access from rules, cancellable analysis, editor-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: TypeScript, running on Bun.** - -### 2.1 Rationale - -| Criterion | TypeScript / Bun | Go | Python | -| --- | --- | --- | --- | -| CLI distribution | npm package; optional 78 MB executable | Single ~2 MB static binary | Needs Python/uv | -| Cold start | **9 ms measured** (3 ms of which is process spawn) | ~5 ms | ~200–400 ms | -| JSON Schema 2019-09 + `unevaluatedProperties` | Ajv 2019, precompiled standalone | `santhosh-tekuri/jsonschema/v6` | `jsonschema` 4.x | -| JSONC parser with positions | **`jsonc-parser` — the parser VS Code itself uses** | Hand-rolled (~700 LOC) | Hand-rolled | -| LSP framework | `vscode-languageserver-node` — the reference implementation | `tliron/glsp`, `go.lsp.dev` | `pygls` | -| Extension integration | **In-process import; no subprocess, no binary to ship** | Spawn a per-platform binary | N/A | - -The measurements above were taken on this machine against a trivial CLI: `bun -build --compile --minify --bytecode` produces a 78 MB executable that starts in 9 ms -median over 30 runs, against a 3 ms floor for `/bin/true`. - -Three arguments decide it. - -**The cold-start objection was aimed at Node, not at Bun.** A ~5 ms versus ~9 ms -difference, half of which is process-spawn overhead that any language pays, is not -something a human operating a linter can perceive. Startup latency is no longer a -differentiator; it was the load-bearing argument for a compiled language and it does -not survive measurement. - -**`jsonc-parser` is not merely a convenient library — it is the parser VS Code -uses.** A linter for a JSONC file whose primary consumer is VS Code has exactly one -thing it must never get wrong: disagreeing with the editor about what the document -says. Adopting the editor's own parser makes that agreement structural rather than -aspirational. Every hand-rolled parser is a standing invitation to diverge on some -escape sequence or recovery decision, and that divergence surfaces as a false -positive in the one place it is least welcome. - -**The extension stops being a client at all.** The argument for Go was that the -extension is a thin client either way, so sharing a language buys little. That holds -only while the server is a separate process. In TypeScript the server is an -`import`: no binary to resolve, no subprocess to spawn or supervise, no -platform-specific VSIX matrix, no version skew between an extension and a binary -released on a different cadence. §10.3 of the Go design specified six build targets -and a CI matrix to copy the right executable into `bin/` before packaging. That -entire section is deleted here rather than ported. - -### 2.2 Accepted costs - -- **78 MB executables.** Bun embeds a full JavaScript engine, and its own - documentation concedes the binary is too big. This is a real and permanent loss - against Go's ~2 MB. It is mitigated, not solved, by making npm the primary - distribution channel (§12): the audience for a `devcontainer.json` linter runs - Node already, and the extension ships no binary at all. Executables remain - available for Docker images and runtime-free CI. -- **No native fuzzer.** Go's `testing.F` has no Bun equivalent. Property-based - testing via `fast-check` covers the same ground with more setup (§11). -- **Ajv generates validator code at runtime.** That fits poorly with - `--compile --bytecode`. We compile validators to standalone modules at build time - instead (§5.5), which is better practice regardless — it makes schema compilation - a build-time cost rather than a per-invocation one. -- **Single-threaded analysis.** Rules run sequentially rather than in goroutines. - For a ~24 KB document this is not a real cost, and it removes a class of data race - the Go design had to reason about (§5.6). -- **A dependency tree.** Go's ethos of near-zero dependencies is not available here. - We hold the line at **three** runtime dependencies in the core — `jsonc-parser`, - `ajv`, and `yaml` — each pinned in the lockfile, audited, and justified in §5. The - LSP server entry point adds `vscode-languageserver` as an optional fourth (§9). - ---- - -## 3. Specification Model - -Facts extracted from the spec that drive the design. This section is -language-independent and is unchanged from the original 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 ───────┤ src/lint (facade) │ - Extension ────────┤ analyze(doc, options, signal) │ - └────────────────────┬───────────────────┘ - │ - ┌──────────────┬──────────────┬────────┴──────┬──────────────┬─────────────┐ - │ discovery │ jsonc │ model │ rules │ report │ - │ locate the │ jsonc-parser│ CST → typed │ engine + │ text/json/ │ - │ config file │ adapter→CST │ semantic AST │ registry │ sarif/gh │ - └──────┬───────┴──────┬───────┴───────┬───────┴──────┬───────┴─────────────┘ - │ │ │ │ - ┌──────┴──────┐ ┌─────┴─────┐ ┌──────┴──────┐ ┌─────┴──────┐ ┌───────────┐ - │ vfs │ │ position │ │ schema │ │ features │ │ diagnostic│ - │ overlay FS │ │ LineIndex │ │ text-import │ │ OCI + cache│ │ Range/Fix │ - │ (unsaved │ │ UTF-16↔ │ │ + standalone│ │ (network) │ │ Severity │ - │ buffers) │ │ display │ │ Ajv 2019 │ │ │ │ │ - └─────────────┘ └───────────┘ └─────────────┘ └────────────┘ └───────────┘ -``` - -### 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 `process.exit`, no thrown exception escaping the core, no `console.*` below - `src/cli`.** The core returns values. Only the CLI entry point decides process - fate. An exception that escapes `analyze()` takes down a language server that is - expected to stay up for hours. -2. **All filesystem access goes through the `FileSystem` interface.** 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 `Bun.file` directly - is unusable in an editor. -3. **Every diagnostic carries a `Range` of UTF-16 code-unit offsets.** This is the - unit JavaScript strings are indexed in, the unit `jsonc-parser` reports, and the - unit the LSP `Position` type is defined in — so the common path requires no - conversion at all. Rendering a terminal caret needs a *display width*, not a byte - count, and that conversion is `src/position`'s job (§5.2). -4. **`analyze()` accepts an `AbortSignal` and honours it.** 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. - -Invariant 3 is the one that changed direction from the original design, which -mandated byte offsets internally. In Go that was correct: strings are byte slices and -UTF-16 is foreign, so byte offsets are the natural internal unit and the LSP edge -pays the conversion. In JavaScript the same reasoning points the opposite way. -Holding byte offsets internally here would mean a `TextEncoder` round-trip at every -boundary — on entry from the parser, and again on exit to the editor — to arrive back -at the unit we started in. - -### 4.3 Package layout - -``` -dcx/ -├── package.json # subpath exports; bin: dcx -├── tsconfig.json -├── src/ -│ ├── cli/ # argv parsing, process exit, stdout — check, explain, feature -│ ├── server/ # LSP server over stdio -│ ├── lint/ # facade: analyze(), Document, Options -│ ├── vfs/ # FileSystem interface, Bun impl, overlay impl -│ ├── position/ # Offset, Range, LineIndex, display-width conversion -│ ├── diagnostic/ # Diagnostic, Severity, Fix, TextEdit -│ ├── jsonc/ # jsonc-parser adapter → CST + comment list -│ ├── discovery/ # config file location per spec §3.1 -│ ├── schema/ # vendored schemas + generated Ajv validators -│ ├── model/ # typed semantic model over the CST -│ ├── features/ # feature ref parsing, OCI resolution, cache -│ ├── registry/ # extension-registry adapters (Open VSX, gallery, policy) -│ ├── lintconfig/ # .dcx.yaml loading + merge -│ ├── suppress/ # inline comment directive parsing -│ ├── rules/ -│ │ ├── engine.ts # registry, ordering, execution -│ │ ├── rule.ts # Rule interface -│ │ └── / # one directory per rule category -│ └── report/ # text, json, sarif, github formatters -├── schemas/ # vendored upstream JSON schemas -├── scripts/ # build-time codegen (Ajv standalone compilation) -├── testdata/ # fixture corpus + golden files -└── extensions/vscode/ # VSCode extension -``` - -The public API surface is declared explicitly through `exports` in `package.json` -rather than by directory convention: - -```json -{ - "exports": { - ".": "./src/lint/index.ts", - "./diagnostic": "./src/diagnostic/index.ts", - "./rules": "./src/rules/index.ts", - "./server": "./src/server/index.ts" - } -} -``` - -Anything not listed is private and may change without a major version. This is -stricter than Go's `pkg/` convention, which exports every capitalised identifier in -every package whether or not that was intended. - -Bun runs TypeScript sources directly, so there is no build step during development -and no `dist/` to keep in sync. The only generated artefacts are the standalone Ajv -validators (§5.5), produced by `scripts/` and committed. - ---- - -## 5. Component Specifications - -### 5.1 `src/jsonc` — the parser adapter - -The original design called for a hand-written lexer and recursive-descent parser, -roughly 700 lines, justified by the absence of a Go library that preserves comments -*and* positions *and* recovers from errors. TypeScript has exactly that library, and -it is the one VS Code uses. `src/jsonc` is therefore an **adapter**, not a parser — -roughly 150 lines. - -`jsonc-parser` supplies, directly: - -| Requirement | Mechanism | -| --- | --- | -| CST with positions | `parseTree()` → nodes with `offset`, `length`, `type`, `colonOffset` | -| Error recovery | Parsing continues past faults; `ParseError[]` is an out-parameter | -| Duplicate keys preserved | Property children are a source-ordered list, not a map | -| Trailing commas | `allowTrailingComma` option; positions recovered via `visit()` | -| Node lookup by JSON Pointer | `findNodeAtLocation(root, path)` | -| Node lookup by offset | `findNodeAtOffset(root, offset)` — the LSP hover/completion primitive | -| Format-preserving edits | `modify()` / `applyEdits()` | - -Two things it does not give us, which the adapter supplies: - -**Comments are not tree nodes.** `parseTree()` discards them; `visit()` reports them -through an `onComment(offset, length, startLine, startChar)` callback. The adapter -runs `visit()` alongside `parseTree()` and collects comments into a source-ordered -side list, then attaches each to the property that follows it. Suppression directives -(§8.2) read from that list. This is a genuine ergonomic loss against a CST with -`Comment` nodes in it, and it is the main cost of the decision — but attaching by -offset is about twenty lines, and it buys the parser itself for free. - -**Trailing commas are permitted, not reported.** With `allowTrailingComma: true` the -parse succeeds silently; with it `false` the position arrives as a `ParseError`. The -adapter parses permissively and locates trailing commas through `visit()`'s -`onSeparator` callback, recording them on the enclosing node so -`syntax/trailing-comma` can report a span. - -The resulting `Document` is: - -```ts -interface Document { - readonly uri: string; - readonly text: string; - readonly root: Node | undefined; // undefined for empty/unparseable input - readonly comments: readonly Comment[]; - readonly errors: readonly ParseError[]; - readonly lines: LineIndex; -} -``` - -Rules consume `Document` and the re-exported `Node` type. Should `jsonc-parser` ever -need replacing, the blast radius is this directory. - -**`modify()` and `applyEdits()` deserve particular note.** They perform -format-preserving edits against the *source text*, honouring the surrounding -indentation and leaving comments intact. In the Go design, every fixable rule had to -construct its own `TextEdit` spans by hand and the convergence tests existed largely -to catch mistakes in that arithmetic. Here, a rule that wants to move root -`extensions` into `customizations.vscode.extensions` expresses it as two `modify()` -calls against JSON paths. §13's M10 shrinks accordingly. - -### 5.2 `src/position` — coordinates - -Internally everything is a **UTF-16 code-unit offset**: the unit JavaScript string -indices use, the unit `jsonc-parser` emits, and the unit LSP `Position` is defined -in. Conversion to an LSP position is therefore a line lookup and a subtraction, with -no character re-encoding on the path an editor exercises on every keystroke. - -`LineIndex` is built once per document — a single pass recording the offset of each -line start — and converts: - -- offset → `{ line, character }` for LSP, by binary search over line starts -- offset → `{ line, column }` for terminal output, where *column* is a **display - width**, not a code-unit count - -The second conversion is the one that carries real complexity, and it is complexity -the original design did not account for. A caret rendered under a span must line up -with what the terminal actually draws, which means accounting for East Asian wide -characters (two columns), combining marks (zero), and tabs (to the next tab stop). A -byte count gets this wrong for exactly the same inputs a code-unit count does; the -Go design's "byte offset ↔ (line, UTF-8 column)" mapping would not have produced a -correctly aligned caret for a CJK container name either. We use -`Bun.stringWidth()`, which implements the width rules natively and requires no -dependency. - -Surrogate pairs are the remaining subtlety. An emoji in a `name` field is one code -point, two UTF-16 code units, and two display columns. Ranges must never split a -surrogate pair; the adapter asserts this in development builds. - -### 5.3 `src/vfs` — filesystem abstraction - -```ts -interface FileSystem { - readFile(path: string): Promise; - stat(path: string): Promise; - readDir(path: string): Promise; -} -``` - -Two implementations: `BunFS` (the CLI, over `Bun.file`) and `OverlayFS` (the LSP — -in-memory documents layered over `BunFS`). Rules receive a `FileSystem` and never -import `Bun.file` or `node:fs` directly. - -`stat` returns `undefined` rather than throwing on a missing path. The `fs/*` rules -(§6.5) exist precisely to report missing paths, so absence is an expected result, not -an exceptional one — and invariant 1 says exceptions do not escape the core. - -### 5.4 `src/model` — semantic model - -Lowers the CST into a typed structure, and — critically — **discriminates the -scenario** before schema validation runs: - -```ts -type Scenario = - | { kind: "unknown" } // no container source found - | { kind: "image"; image: Field } - | { kind: "dockerfile"; dockerfile: Field; legacy: boolean } - | { kind: "compose"; files: Field[]; service: Field | undefined } - | { kind: "ambiguous"; sources: Field[] } // more than one of the above - | { kind: "metadataOnly" }; // valid: common properties only -``` - -A discriminated union rather than Go's `iota` enum, and the difference is not -cosmetic. The Go version carried a `Scenario` integer and left every consumer to -re-derive which fields were populated; here the payload travels with the tag, and a -`switch` over `kind` that forgets a case is a compile error under `strict`. The -`ambiguous` case carrying its conflicting sources is what lets -`scenario/conflicting-source` name both offenders with spans rather than reporting a -generic conflict. - -Every field on the model retains a back-pointer to its CST node: - -```ts -interface Field { - readonly value: T; - readonly node: Node; // for the span -} -``` - -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"`. - -### 5.5 `src/schema` — schema validation - -The upstream schemas are vendored into `schemas/` — 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. - -Where Go used `go:embed`, Bun uses a **text import**, which embeds the file contents -into the module graph at build time: - -```ts -import baseSchema from "../../schemas/devContainer.base.schema.json" with { type: "text" }; -``` - -In a compiled executable the text is stored once in the engine's own string -representation and handed back without a copy. - -Validation uses **Ajv 2019** (`ajv/dist/2019`), which supports draft 2019-09 -including `unevaluatedProperties`. - -**Validators are compiled at build time, not at startup.** Ajv's normal mode -generates validator source and evaluates it with `new Function`. That is a poor fit -for a compiled executable with `--bytecode`, and it charges every single invocation -for compiling a 24 KB schema. Instead, `scripts/build-validators.ts` runs Ajv with -`code: { source: true, esm: true }` and writes standalone ESM modules into -`src/schema/generated/`, which are committed and imported like ordinary code. The -schema becomes a build-time input, dead code is eliminated by the bundler, and no -code is generated at runtime. The weekly drift job regenerates these alongside the -vendored schema, so a stale validator is a CI failure rather than a silent -divergence. - -**Error translation is a first-class concern.** Ajv reports errors as -`{ instancePath, schemaPath, keyword, params, message }`, where `instancePath` is a -JSON Pointer. Raw 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. Ajv reports every - failed branch, so an unfiltered run against this schema produces dozens of errors - for a single mistake — this step is what makes the output usable at all. -2. Maps `instancePath` to a CST node for an exact span. The pointer splits into path - segments and goes straight into `findNodeAtLocation(root, path)`, so this is a - lookup rather than a traversal we write ourselves. -3. Rewrites the message into prose, adding the enum's valid values (from - `params.allowedValues`), a spelling suggestion for unknown properties - (Levenshtein over the known key set), and a documentation link. - -Ajv must run with `allErrors: true` so step 1 has a full set to filter. - -### 5.6 `src/rules` — the rule engine - -```ts -interface Rule { - readonly id: string; // e.g. "security/docker-socket-mount" - readonly description: string; - readonly defaultSeverity: Severity; - readonly category: Category; - readonly requiresNetwork: boolean; - check(pass: Pass): void | Promise; -} - -interface Pass { - readonly doc: Document; // CST + source text - readonly model: DevContainer; - readonly fs: FileSystem; - readonly dir: string; // directory containing devcontainer.json - readonly features: FeatureResolver | undefined; // undefined when offline - readonly signal: AbortSignal; - report(d: Diagnostic): void; -} -``` - -Rules are registered by **explicit import into a manifest**, not by a side effect at -load time. Go's `init()`-based self-registration has no safe equivalent here: module -side effects run on import, and a bundler is free to drop or reorder a module whose -exports are unused. `src/rules/index.ts` lists every rule explicitly. The cost is one -line per rule; the benefit is that tree-shaking, test isolation, and rule ordering -all become predictable, and a rule that was never imported fails a registry -completeness test rather than silently not running. - -The engine: - -1. Filters by config (severity `off`) and by `requiresNetwork` when offline. -2. Runs offline rules **sequentially**, awaiting `null` between rules to yield to the - event loop. Network-dependent rules run concurrently via `Promise.all`, since they - are I/O-bound and that is where concurrency actually pays. -3. Collects diagnostics, applies inline suppressions, sorts by position. -4. Checks `signal.aborted` between rules for LSP cancellation. - -Point 2 is a deliberate simplification of the Go design, which ran all rules -concurrently. For a document measured in kilobytes the offline rule set is -single-digit milliseconds of pure CPU work; parallelising it across workers would -cost more in structured-clone overhead than it saves. Sequential execution also makes -diagnostic ordering deterministic without a sort key tiebreaker, and removes any -question of two rules observing the model mid-mutation. - -### 5.7 `src/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`. - -OCI registry access is plain `fetch` against the distribution API — a token request -against the registry's auth endpoint, then a manifest fetch, then a blob fetch. -No client library is required and none is taken. - -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 `src/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:** - -```ts -interface Source { - readonly id: string; - readonly requiresNetwork: boolean; - lookup(publisher: string, name: string, signal: AbortSignal): Promise; -} - -interface Extension { - readonly version: string; - readonly deprecated: boolean; - readonly downloadable: boolean; // false ⇒ unpublished or removed - readonly allowedVersions: string[] | undefined; // from policy sources; undefined ⇒ unconstrained - readonly 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 - -Two different concerns are in play, and they have different owners. - -- **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`. - -This catalog describes the spec, not the implementation language, and is unchanged. - -### 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** — 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. - -Argument parsing uses Bun's built-in `parseArgs` (`node:util`). No dependency is -taken for this; the flag set below is entirely expressible in it, and a CLI framework -would be the single largest dependency in the project for the least benefit. - -### 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`. `Bun.Glob` supplies the traversal. - -### 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 | - -Colour detection uses `Bun.color` and honours `NO_COLOR`, `FORCE_COLOR`, and TTY -detection in that order. - -### 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". Per invariant 1, `process.exit` is called in exactly one place — -`src/cli/main.ts` — and an unexpected exception is caught there, reported as an -internal error, and turned into exit 2. - -### 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 -``` - -Caret alignment uses the display-width conversion from §5.2, so the underline lines -up under non-ASCII content rather than drifting. - -`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. - -Regions are emitted as `startLine`/`startColumn`/`endLine`/`endColumn`. SARIF columns -are 1-based character offsets, which our UTF-16 offsets convert to directly; the -`byteOffset` properties the Go design would have used are optional and are omitted. - ---- - -## 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 parsing uses the `yaml` package. This is the third and last runtime dependency. -Bun has no built-in YAML parser, and hand-rolling one to read a config file would be -the worst kind of not-invented-here. - -```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 -``` - -The loaded config is validated by its own Ajv validator, generated by the same -build-time step as the devcontainer schema (§5.5), so a malformed `.dcx.yaml` -produces a diagnostic with a span rather than a runtime type error. - -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. - -Directives are read from the comment side list produced by the parser adapter -(§5.1), matched to diagnostics by line. - ---- - -## 9. LSP Integration Plan - -The server is a **thin adapter**, not a second implementation. It is built once the -CLI rule set is stable (M11), and its existence is what §4.2's invariants pay for. - -It uses `vscode-languageserver-node`, which is the reference implementation of the -protocol rather than a third-party binding — the same codebase VS Code's own language -servers are built on. It is a runtime dependency of the `./server` entry point only -and is declared `optional`, so installing `dcx` for CLI or CI use does not pull it -in. The core library never imports it. - -### 9.1 Server surface - -| Capability | Backed by | -| --- | --- | -| `textDocument/publishDiagnostics` | `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" | - -Hover and completion both need "which node is under the cursor", which is -`findNodeAtOffset()` from the parser adapter — a function we get rather than write. - -### 9.2 Mechanics - -- Transport: stdio (`--stdio`), matching every editor's expectation. -- Sync: incremental (`TextDocumentSyncKind.Incremental`); `OverlayFS` holds buffers. -- Cancellation: each `didChange` aborts the previous analysis via its `AbortController`. -- Positions: offsets are already UTF-16 code units, so an LSP `Position` is a line - lookup and a subtraction (§5.2). -- The server shares the CLI's config discovery, so a project's `.dcx.yaml` governs - the editor identically. - -### 9.3 One package, not two - -The server is a **subcommand of the same package**, not a separate artefact: -`dcx serve --stdio` alongside `dcx check`. Three reasons: - -1. **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. -2. **Users install one thing.** `bun add -d dcx` gives you the CLI and the language - server, and the extension needs nothing further. -3. **There is no packaging pressure to split.** The Go design had to weigh bundling - one binary against two; here both are entry points in a package that the extension - imports directly. - -The `exports` surface in §4.3 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 — and substantially smaller -than the Go design's equivalent, because there is no binary to find, ship, version, -or spawn. - -### 10.1 Two-phase plan - -**Phase 1 (M8) — in-process, direct.** No LSP required. The extension imports the -lint facade directly, calls `analyze()` on open and on save, and populates a -`DiagnosticCollection`. Roughly 100 lines. There is no subprocess, no JSON parsing of -another process's stdout, and no error path for "the binary is missing". - -**Phase 2 (M12) — LSP-driven.** Replace the direct call with -`vscode-languageclient`, running `src/server` in a Node IPC transport. Diagnostics -arrive over the protocol; hover, completion, code actions, and document links come -along with it. The Phase 1 code path is deleted, not maintained in parallel. - -The reason to move to Phase 2 at all is not the extension — Phase 1 serves VS Code -perfectly well. It is Neovim, Helix, and Zed, which need a real server over stdio. - -### 10.2 Binary resolution - -None required. The Go design needed a three-step resolution order — `dcx.path` -setting, then a bundled binary in `bin/`, then `PATH` — plus a notification for the -case where all three failed. The extension bundles the analyser as JavaScript and -runs it in the extension host, so none of that exists. - -A `dcx.path` setting is retained for one narrow case: pointing the extension at a -locally built checkout during development on dcx itself. - -### 10.3 Packaging - -One VSIX, all platforms. `bun build --target=node` bundles the extension and the -analyser into a single JavaScript file of roughly 200 KB, and `vsce package` ships -it. - -This replaces the Go design's six platform-specific VSIX targets, the CI matrix that -copied the right executable into `bin/` before packaging, and the ~6 MB per-platform -payload. It is the single largest simplification in this document. - -### 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` | `""` | Point at a local checkout (development only) | -| `dcx.run` | `onSave` | `onSave` \| `onType` | -| `dcx.online` | `false` | Enable network rules | -| `dcx.configPath` | `""` | Explicit config file | -| `dcx.trace.server` | `off` | LSP tracing (Phase 2) | - -Commands: *Lint Workspace*, *Fix All Auto-fixable Problems*, *Restart Server*, -*Show Output*. - ---- - -## 11. Testing Strategy - -`bun test` throughout — Jest-compatible API, built in, no runner dependency and no -transform configuration. - -| Layer | Approach | -| --- | --- | -| **Parser adapter** | Table-driven unit tests over the adapter's additions: comment attachment, trailing-comma positions, error surfacing. We do not re-test `jsonc-parser` itself. | -| **Rules** | Golden-file tests: `testdata/rules//.jsonc` with `// want: error: …` annotations inline. The annotation sits on the line the diagnostic must target, so span correctness is tested implicitly. | -| **Schema translation** | Snapshot tests (`toMatchSnapshot`) over the rewritten message for each error class | -| **CLI** | End-to-end tests over `testdata/projects/*` asserting stdout, stderr, and exit code, driven through `Bun.$` | -| **Formatters** | Golden files; SARIF output validated against the SARIF 2.1.0 schema by a generated Ajv validator | -| **Corpus** | A vendored set of ~200 real `devcontainer.json` files harvested from public repos. CI asserts zero exceptions 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 | -| **Property-based** | `fast-check` over the analyser: arbitrary JSONC input must never throw, and every reported range must be within document bounds and must not split a surrogate pair | -| **Extension** | `@vscode/test-electron` integration test asserting diagnostics appear for a fixture workspace | - -Two notes on what changed. - -**There is no native fuzzer.** Go's `testing.F` with coverage-guided mutation has no -Bun equivalent, and this is a genuine loss — it is the tool that finds the input you -did not think of. `fast-check` with a JSONC-shaped arbitrary plus a mutation pass over -the corpus covers most of the same ground, but through generators we have to write. -The mitigating factor is that the highest-risk component, the parser, is now a -widely-deployed library rather than 700 lines of our own recursive descent. - -**The corpus test remains 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 - -npm is the primary channel. The audience for a `devcontainer.json` linter overwhelmingly -has a JavaScript runtime already, and the payload difference is three orders of -magnitude — a published package of roughly 300 KB against a 78 MB executable. - -| Channel | Mechanism | Payload | -| --- | --- | --- | -| **npm** | `bunx dcx check`, or `bun add -d dcx` / `npm i -D dcx` | ~300 KB | -| **Executables** | `bun build --compile --target=` for the 8 supported targets; attached to GitHub Releases with checksums and SBOM | ~78 MB each | -| **Homebrew** | Tap wrapping the executable | ~78 MB | -| **Docker** | `ghcr.io/lonhutt/dcx`, `oven/bun`-based | ~120 MB | -| **pre-commit** | `.pre-commit-hooks.yaml` with a `node` hook, plus a binary-download hook | — | -| **GitHub Action** | Composite action running `bunx dcx`, uploading SARIF | — | -| **VSCode** | Marketplace + Open VSX, one VSIX for all platforms | ~200 KB | - -Executables exist for the environments that genuinely have no runtime — a distroless -CI image, a bootstrapping script — and are honestly labelled as the heavyweight -option. Note that `bun build --compile` cross-compiles from any host to all eight -targets, so the release job is a single-runner loop rather than a matrix of runners. - -Scoop and the Linux packages (`.deb`/`.rpm`/`.apk`) from the Go design are dropped. -GoReleaser produced them nearly for free; here each would be hand-rolled packaging -around a 78 MB payload for an audience already served by npm. - -### 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. - -The `exports` map in §4.3 is versioned on the same policy: adding a subpath is minor, -removing or narrowing one is major. - ---- - -## 13. Milestones - -| # | Milestone | Content | Exit criterion | -| --- | --- | --- | --- | -| **M0** | Foundations | Repo, `package.json`, CI (test/typecheck/lint), `position`, `vfs`, `diagnostic` modules | CI green; `position` round-trips the Unicode test corpus | -| **M1** | JSONC adapter | `jsonc-parser` wrapper, comment side-list and attachment, trailing-comma positions, `Document` type | Parses the 200-file corpus with zero exceptions; comment attachment verified against fixtures | -| **M2** | Schema layer | Vendored schemas, text imports, build-time Ajv standalone generation, scenario discrimination, error translation | Every schema error class yields a human-readable message with an exact span | -| **M3** | Rule engine | `Rule` interface, manifest registry, sequential execution, config file, inline suppressions | Engine runs with a trivial rule set; suppressions tested; registry completeness test passes | -| **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, npm publish, executables, Docker, pre-commit, GH Action | `v0.1.0` published and installable via `bunx` | -| **M8** | VSCode extension v1 | In-process diagnostics, single 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 via `modify()`, `--fix`, `--fix-dry-run`, convergence tests | All rules marked *fixable* apply cleanly | -| **M11** | LSP server | `src/server`, 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. - -M1, M8, and M10 are materially cheaper than their Go equivalents — the parser is a -wrapper rather than a recursive-descent implementation, the extension ships no -binary, and fixes are expressed as `modify()` calls against JSON paths rather than -hand-computed edit spans. M0 and M2 are slightly more expensive: `position` carries -display-width handling the Go design underspecified, and M2 gains a build-time -codegen step. - ---- - -## 14. Resolved Decisions - -| # | 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. | -| 9 | Runtime dependency budget | **Three in the core, and they are named:** `jsonc-parser`, `ajv`, `yaml`. Anything else must displace one of them or be written in-tree. The `./server` entry point adds `vscode-languageserver` as an optional dependency, not installed for CLI use. | -| 10 | Node compatibility | **Bun is the development and primary runtime; the published package must also run on Node 22+.** Bun-specific APIs (`Bun.file`, `Bun.Glob`, `Bun.stringWidth`) are confined to `src/vfs`, `src/cli`, and `src/position`, each behind a narrow interface with a Node fallback. The core is runtime-agnostic, which is also what lets the VSCode extension host run it unchanged. | - ---- - -## 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 via `Bun.$`, 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. - -### D4 — Worker-parallel corpus linting — *low* - -`--recursive` over a monorepo with hundreds of dev container configs is the one case -where single-threaded analysis (§5.6) could become noticeable. If it does, the fix is -`Worker` over *files*, not over rules: each worker owns a document end to end, so the -structured-clone cost is one string in and one diagnostic array out. - -- **Trigger:** a measured `--recursive` run exceeding ~2 s on a real repository. -- **Blocked by:** M5. **Blocks:** nothing. - ---- - -## 16. Summary of Key Decisions - -| Decision | Choice | Reason | -| --- | --- | --- | -| Language | TypeScript on Bun | 9 ms measured start; the whole target ecosystem — parser, LSP framework, extension host — is TypeScript | -| Parser | `jsonc-parser` behind a thin adapter | It is the parser VS Code uses; agreeing with the editor becomes structural, not aspirational | -| Schema | Vendored + text import, Ajv 2019 compiled standalone at build time | Offline-deterministic; draft 2019-09 + `unevaluatedProperties`; no runtime codegen | -| Error quality | Discriminate scenario *before* validating | Turns `oneOf` noise into actionable prose — the core value of the project | -| Filesystem | `FileSystem` interface everywhere | Unsaved editor buffers are the whole reason an LSP needs it | -| Positions | UTF-16 code units internally; display width at the terminal edge | The unit the parser, the language, and the protocol already share | -| Rule registration | Explicit manifest, not load-time side effects | Bundler-safe and testable; `init()` has no safe equivalent | -| Concurrency | Sequential offline, concurrent for network I/O | Parallelism where it pays; determinism where it doesn't | -| Fixes | `Diagnostic.fix` from rule #1, via `modify()` | Retrofitting fixes means rewriting every rule; format-preserving edits come free | -| 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 package | One thing to install and version | -| Extension | Phase 1 in-process, Phase 2 LSP | Ships value early; Phase 2 exists for Neovim and Zed, not for VS Code | -| Distribution | npm primary, executables secondary | 300 KB against 78 MB, for an audience that has a runtime already | - ---- - -## 17. Assessment - -What this language choice actually costs and buys, stated plainly. - -### 17.1 What got better - -**The parser stops being ours.** §5.1 falls from ~700 lines of hand-written lexer and -recursive-descent parser to a ~150 line adapter, and — more importantly — the -remaining risk moves from our code to Microsoft's. For a tool whose correctness is -defined as *agreeing with VS Code about what this file says*, using VS Code's parser -is not a convenience but a correctness argument. - -**The extension stops being a distribution problem.** Six platform-specific VSIX -targets, a CI matrix to place the right executable in `bin/`, a three-step binary -resolution order, and the whole class of "the bundled binary doesn't match the -extension version" bug are deleted rather than ported. One VSIX, ~200 KB, everywhere. - -**Fixes get cheaper.** `modify()` / `applyEdits()` perform format-preserving edits -against JSON paths, so M10 stops being an exercise in hand-computed edit arithmetic. - -**Positions get simpler.** UTF-16 offsets are what the parser emits, what the -language indexes strings by, and what the protocol is defined in. The conversion the -Go design had to perform on the editor's hottest path does not exist. - -### 17.2 What got worse - -**Binary size: 78 MB against roughly 2 MB, measured.** This is the real loss and -there is no mitigation that makes it go away — only the observation that npm, not the -executable, is now the path almost everyone takes. Anyone who genuinely needs a -runtime-free single file is worse off by a factor of forty. - -**No coverage-guided fuzzer.** `fast-check` covers similar ground through generators -we write rather than mutation the tool discovers. Partly offset by the parser no -longer being ours to fuzz. - -**A dependency tree.** Three runtime dependencies against Go's near-zero-dependency -norm, plus a transitive graph and a supply chain to watch. Decision 9 caps it. - -**Single-threaded.** Immaterial for one document, potentially material for -`--recursive` over a monorepo. D4 holds the escape hatch. - -### 17.3 The argument that did not survive - -The Go design's §2.1 rested on TypeScript costing 150–300 ms to start. That figure -describes Node. Bun starts this CLI in **9 ms median over 30 runs**, of which 3 ms is -process-spawn overhead any language pays — against the ~5 ms the Go design claimed -for itself. A 4 ms difference on a tool a human invokes on save is not a -differentiator, and it was the load-bearing argument for compiling ahead of time. - -What remains of the original case for Go is binary size, and binary size mattered -chiefly *because* the extension had to bundle the thing. In TypeScript it does not. -The two costs were load-bearing for each other, and neither stands alone. diff --git a/package.json b/package.json index b224963..c5bd12b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "build": "bun build ./index.ts --compile --minify --sourcemap --bytecode --outfile dist/dcx", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test": "bun test" }, "dependencies": { "ajv": "^8.20.0", diff --git a/src/schema/provenance.json b/src/schema/provenance.json new file mode 100644 index 0000000..38c49f1 --- /dev/null +++ b/src/schema/provenance.json @@ -0,0 +1,17 @@ +{ + "devContainer.base.schema.json": { + "url": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainer.base.schema.json", + "commit": "c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421", + "retrieved": "2026-09-15" + }, + "devContainer.schema.json": { + "url": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainer.schema.json", + "commit": "c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421", + "retrieved": "2026-09-15" + }, + "devContainerFeature.schema.json": { + "url": "https://raw.githubusercontent.com/devcontainers/spec/c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421/schemas/devContainerFeature.schema.json", + "commit": "c95ffeed1d059abfe9ffbe79762dc2fa4e7c2421", + "retrieved": "2026-09-15" + } +} diff --git a/src/schema/schemas.test.ts b/src/schema/schemas.test.ts index 703e343..1daac8d 100644 --- a/src/schema/schemas.test.ts +++ b/src/schema/schemas.test.ts @@ -1,21 +1,82 @@ -import { test } from "bun:test"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import type { ValidateFunction } from "ajv"; +import { expect, test } from "bun:test"; +import { validate as validateBaseRaw } from "./generated/devContainer.base.validator"; +import { validate as validateFeatureRaw } from "./generated/devContainerFeature.validator"; +import { provenance } from "./schemas"; -// Nothing to assert yet — provenance() throws until DCL-10 lands, and the generated -// validators under src/schema/generated/ don't exist until scripts/build-validators.ts -// has actually run. test.todo marks intent without running or failing these. +// The generated modules are plain standalone Ajv output with no .d.ts of their own; +// TypeScript's allowJs inference misses the `.errors` property Ajv assigns at runtime. +const validateBase = validateBaseRaw as unknown as ValidateFunction; +const validateFeature = validateFeatureRaw as unknown as ValidateFunction; -test.todo("every generated validator compiles and validates a known-good fixture", () => {}); -test.todo( - "provenance() has one entry per vendored schema, each with a url, commit and date", - () => {}, -); -test.todo( - "vendored files match the submodule's copies (skipped when the submodule is absent)", - () => {}, -); -test.todo("offline determinism — same input, identical output, no network reachable", () => {}); -test.todo( - "unevaluatedProperties is genuinely enforced by the generated validator " + - "(catches importing the wrong Ajv entrypoint — see DCL-10's implementation notes)", - () => {}, +// One directory up from src/schema/ is src/, two is the repo root. +const repoRoot = path.join(import.meta.dir, "..", ".."); +const schemasDir = path.join(repoRoot, "schemas"); +const submoduleDir = path.join(repoRoot, "devcontainer-spec"); + +test("every generated validator compiles and validates a known-good fixture", () => { + 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}$/); + } +}); + +// schemas/ is a symlink into the pinned devcontainer-spec submodule, so there is no +// file to diff against upstream — the thing that can actually drift is the pin. A +// fresh clone without `--recurse-submodules` leaves devcontainer-spec/ present but +// empty (no `.git`), which is when this degrades to skipped rather than failed. +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, identical output, no network reachable", () => { + // The generated validators are pure synchronous functions over their input — there + // is no fetch anywhere on this path — so running the same input twice must produce + // byte-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 importing the wrong Ajv entrypoint (ajv/dist/2019 vs. the draft-07 + // default) — the wrong one accepts this fixture silently instead of rejecting it. + 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 index decbd15..6b241b5 100644 --- a/src/schema/schemas.ts +++ b/src/schema/schemas.ts @@ -1,10 +1,14 @@ import { validate } from "./generated/devContainer.base.validator"; +import schemas from "./provenance.json" with { type: "json" }; /** - * Per-schema provenance: where each vendored file in `schemas/` came from and when it - * was last checked against the `devcontainer-spec` submodule (DCL-10). + * Per-schema provenance: the upstream URL, the `devcontainer-spec` submodule commit + * it was retrieved at, and the retrieval date (DCL-10). * - * TODO(DCL-10): read from `schemas/provenance.json`, not written yet. + * `schemas/` is a symlink into the pinned submodule, not an independent copy, so this + * data — not a file diff — is what a drift check has to compare against upstream. + * + * TODO(DCL-10): read from `./provenance.json` (a JSON import), not implemented yet. */ export interface Provenance { readonly url: string; @@ -16,5 +20,9 @@ export interface Provenance { * TODO(DCL-10): not implemented. One entry per vendored schema, keyed by filename. */ export function provenance(): Record { - throw new Error("not implemented"); + return { + "devContainer.base.schema.json": schemas["devContainer.base.schema.json"], + "devContainer.schema.json": schemas["devContainer.schema.json"], + "devContainerFeature.schema.json": schemas["devContainerFeature.schema.json"], + }; } From 78f4a9b262bbd39512516127fd0f746fb46cddc9 Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Wed, 23 Sep 2026 14:50:09 -0600 Subject: [PATCH 6/9] ci syntax cleanup --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9b87a3c..56a8246 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,8 +38,8 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Build - run: bun ci - run: bun run build + - run: bun ci + - run: bun run 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 From 21818ed4205f30c16c8529196a754d00d5fd8eac Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Wed, 23 Sep 2026 16:14:17 -0600 Subject: [PATCH 7/9] fixed ci --- .github/workflows/ci.yaml | 91 ++++++++++++++++++++------------------- .prettierignore | 3 ++ CLAUDE.md | 1 - 3 files changed, 49 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 56a8246..b9f207e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,63 +16,64 @@ 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 + # 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: 5 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 } + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.2" - - name: Build - - run: bun ci - - run: bun run build + - run: bun install --frozen-lockfile - # 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. - 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 }} - strategy: - fail-fast: false - matrix: - include: - - { os: ubuntu-latest, race: true } - - { os: macos-latest, race: false } - - { os: windows-latest, race: false } + - run: bun test + + # 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 + path: coverage/lcov.info + + # 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: 5 steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.2" - - name: Format - run: bun run format:check + - run: bun install --frozen-lockfile + - run: bunx tsc --noEmit + - run: bun run format:check - - name: Test - if: ${{ !matrix.race }} - run: bun test + # 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: 5 + steps: + - uses: actions/checkout@v4 - - name: Coverage summary - if: ${{ matrix.race }} - uses: codecov/codecov-action@v3 + - uses: oven-sh/setup-bun@v2 with: - file: ./coverage/lcov.info - fail_ci_if_error: true + bun-version: "1.4.2" + + - run: bun install --frozen-lockfile + - run: bun run build diff --git a/.prettierignore b/.prettierignore index fcb64ac..6853af8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ schemas testdata bun.lock + +# Standalone Ajv output, generated by scripts/build-validators.ts — committed, not ours to format +src/schema/generated diff --git a/CLAUDE.md b/CLAUDE.md index 764c1dd..8baee3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,3 @@ - Default to using Bun instead of Node.js. - Use `bun ` instead of `node ` or `ts-node ` From c6f791918f595f6ac76fc99933aae49cf75b7ffd Mon Sep 17 00:00:00 2001 From: Lon Hutt Date: Tue, 29 Sep 2026 12:11:17 -0600 Subject: [PATCH 8/9] CI fix, update position.ts and tests --- .gitattributes | 3 + .github/workflows/ci.yaml | 9 + src/position/oracle.test.ts | 155 ++ src/position/position.test.ts | 188 +++ src/position/position.ts | 106 ++ src/position/testdata/README.md | 13 + src/position/testdata/large-commented.jsonc | 1558 +++++++++++++++++++ src/position/testdata/mixed-script.jsonc | 7 + src/vfs/vfs.test.ts | 4 +- 9 files changed, 2042 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 src/position/oracle.test.ts create mode 100644 src/position/position.test.ts create mode 100644 src/position/position.ts create mode 100644 src/position/testdata/README.md create mode 100644 src/position/testdata/large-commented.jsonc create mode 100644 src/position/testdata/mixed-script.jsonc diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b0a3f8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Position fixtures depend on their exact line terminators (\n, \r\n, lone \r); +# stop autocrlf on Windows checkouts from normalising them. +src/position/testdata/*.jsonc -text diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9f207e..d649a12 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,12 @@ jobs: matrix: 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 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: @@ -52,7 +57,11 @@ jobs: runs-on: ubuntu-latest 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 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: diff --git a/src/position/oracle.test.ts b/src/position/oracle.test.ts new file mode 100644 index 0000000..afd4656 --- /dev/null +++ b/src/position/oracle.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { LineIndex, type Position, type TerminalPosition } from "./position"; + +// This file is the reference implementation LineIndex is tested against. +// +// Everything here is deliberately the slowest, most obviously correct code that could +// work: a linear scan for the line, a subtraction for the character, and a split on +// tabs for the column. Nothing in it should ever be made clever. Its only job is to be +// so simple that when it disagrees with LineIndex, the bug is in LineIndex. +// +// The one concession is that line starts are computed once per document rather than +// per call — without it the large fixture is O(n²) in splitting alone. +// +// Bun.stringWidth is trusted for everything except tabs (it reports a tab as 0 +// columns). The oracle only has to get the tab-stop walk obviously right; per-glyph +// width rules are the library's job, 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); + } + } + + /** The offset LineIndex must 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 code-unit offset in `text`, and checks the + * round trip. Returns the first disagreement, or undefined — so a failure prints the + * input that caused it rather than a bare `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(); + }); +}); + +// No coverage-guided fuzzer exists for Bun (Design §11), so this is a seeded loop over +// generated documents. The generator is weighted toward the inputs that break position +// code — terminators, tabs, surrogate pairs, zero- and double-width glyphs — rather than +// uniform over Unicode, where almost everything would be an unremarkable BMP letter. +describe("property: random documents", () => { + const pieces = [ + "a", + "Z", + " ", + "{", + '"', + "\n", + "\r\n", + "\r", + "\t", + "中", + "ア", + "́", + "😀", + "👨‍👩‍👧", + "🇯🇵", + "\uD83D", // a lone surrogate: malformed, but a string can hold it and so can a file + ]; + + /** mulberry32: a tiny seeded PRNG, so a failure reproduces from its seed alone. */ + 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..60b0dea --- /dev/null +++ b/src/position/position.test.ts @@ -0,0 +1,188 @@ +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 must always be addressable: every 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 defaults back to the line length, where "length" +// is the visible text. Bounding the scan by the end of the *document* instead walks an +// overlong character on into the following lines — and incremental didChange (§9.2) +// turns that result straight into a splice point. +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); + }); +}); + +// A position between "\r" and "\n" lies past the line's visible end. It snaps back to +// the "\r", and the position it yields converts back to that snapped offset rather +// than to a character its own inverse would reject. +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); + }); +}); + +// Invariant 1: nothing throws. An offset from a stale parse can outlive the edit that +// shortened the document, and an LSP client can send any line it likes. +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); + }); +}); + +// Three different lengths for one emoji: "😀".length === 2 (code units), one code point, +// and Bun.stringWidth("😀") === 2 columns. The first and third agreeing is a coincidence +// of this emoji — 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, so that any other throw (a stub's included) doesn't pass. + 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..33a4040 --- /dev/null +++ b/src/position/position.ts @@ -0,0 +1,106 @@ +/** + * The single coordinate system for the whole tool (Design §5.2, §4.2 invariant 3). + * + * Everything internal is a UTF-16 code-unit {@link Offset} — the unit JS strings, + * `jsonc-parser` and LSP `Position` all share. Conversion to a display column happens + * only at the terminal edge, and only here: no caller 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; +} + +/** + * Line structure of one document, built once and queried many times. + * + * `\n`, `\r\n` and a lone `\r` all terminate a line. A document ending in a terminator + * has a final empty line, and EOF is always addressable. No method throws: every + * out-of-range input clamps into the document (invariant 1). + * + * TODO(DCL-03): not implemented. + */ +export class LineIndex { + /** Records every line-start offset of `text` in a single pass. */ + constructor(text: string) { + throw new Error("not implemented"); + } + + /** Number of lines; always at least 1, even for an empty document. */ + get lineCount(): number { + throw new Error("not implemented"); + } + + /** Offset at which `line` begins; `line` clamps to `[0, lineCount - 1]`. */ + lineStart(line: number): Offset { + throw new Error("not implemented"); + } + + /** 0-based line containing `offset`; `offset` clamps to `[0, text.length]`. */ + lineAt(offset: Offset): number { + throw new Error("not implemented"); + } + + /** + * Visible extent of `line`, excluding its terminator, so a highlight over a whole line + * never wraps into the next. `line` clamps as in {@link lineStart}. + */ + lineRange(line: number): Range { + throw new Error("not implemented"); + } + + /** + * LSP position of `offset`. An offset inside a `\r\n` pair is not a position; it snaps + * back to the `\r`. An offset inside a surrogate pair is returned as-is — rejecting + * those is {@link assertNoSplitSurrogate}'s job, not this one's. + */ + positionAt(offset: Offset): Position { + throw new Error("not implemented"); + } + + /** + * Inverse of {@link positionAt}. `line` clamps to the document; a `character` past + * the line's visible end falls back to that end, per the LSP convention — never into + * the terminator or the next line. + */ + offsetAt(position: Position): Offset { + throw new Error("not implemented"); + } + + /** + * Terminal position of `offset`: the column is 1 plus the display width of the line + * text before it, with East Asian wide characters counting 2, combining marks 0, and + * each tab advancing to the next multiple of `tabWidth`. Clamps like {@link positionAt}. + */ + terminalPositionAt(offset: Offset, tabWidth = 8): TerminalPosition { + throw new Error("not implemented"); + } +} + +/** + * Throws — with "surrogate" in the message — if either end of `range` falls between the + * two halves of a surrogate pair in `text`. A development-build guard for `Range` construction sites the parser does not + * already guarantee; a lone (unpaired) surrogate has no pair to split and is accepted. + * + * TODO(DCL-03): not implemented. + */ +export function assertNoSplitSurrogate(text: string, range: Range): void { + throw new Error("not implemented"); +} diff --git a/src/position/testdata/README.md b/src/position/testdata/README.md new file mode 100644 index 0000000..5977f7f --- /dev/null +++ b/src/position/testdata/README.md @@ -0,0 +1,13 @@ +# position test fixtures + +`large-commented.jsonc` — a real `devcontainer.json` from +`Ilenburg1993/chatgpt-docker-puppeteer`, retrieved 2026-09-04. The widest gap between +bytes and characters found in a survey of public dev container configs: 1,885 non-ASCII +characters and 9 astral-plane emoji across 1,558 lines. The p100 tail; the median +real-world `devcontainer.json` is about 1.7 KB. + +`mixed-script.jsonc` — hand-built to put every width class and every line terminator +on one page: CJK (2 columns), half-width katakana (1), a combining mark (0), tabs at +line start and mid-line, an astral emoji, a ZWJ family (one 2-column glyph from 8 code +units), a flag, and `\n`, `\r\n` and a lone `\r`. Its exact bytes matter — do not let +an editor normalise its line endings. diff --git a/src/position/testdata/large-commented.jsonc b/src/position/testdata/large-commented.jsonc new file mode 100644 index 0000000..33c54ec --- /dev/null +++ b/src/position/testdata/large-commented.jsonc @@ -0,0 +1,1558 @@ +// ============================================================================ +// DevContainer — Ambiente de Desenvolvimento Controlado - v5.9.1 +// Projeto: chatgpt-docker-puppeteer +// +// Propósito: +// Definir um ambiente de desenvolvimento determinístico, auditável e +// arquiteturalmente neutro, no qual: +// +// • A infraestrutura fornece CAPACIDADE (runtime, rede, volumes) +// • O sistema decide COMPORTAMENTO (topologia, browser, orquestração) +// • O editor (VS Code) não interfere em planos de controle internos +// +// Princípios: +// • Deny-by-default para portas +// • Puppeteer opera exclusivamente em modo "connect" +// • Chrome é externo ao container (host ou serviço dedicado) +// • Nenhuma decisão de topologia é hardcoded na infraestrutura +// • Debug e observabilidade são opt-in e não intrusivos +// • Documentação robusta (NUNCA reduzir) +// +// Escopo: +// • Ambiente DEV (não produção) +// • Suporte a Puppeteer, Node.js, PM2, Docker CLI (host socket) +// • Estabilidade e previsibilidade > conveniência automática +// +// Nota Arquitetural Importante: +// Portas de controle (ex.: Chrome Proxy / Debug) não são auto-expostas por +// inferência. Quando declaradas, são forwardadas explicitamente e com política +// de UX silenciosa/ignore. O acesso operacional continua decidido pelo runtime. +// +// CHANGELOG v5.9.1 (2026-08-18): +// 🌐 NETWORK OBSERVABILITY + CONTROL-PLANE PATH SELF-HEALING +// ✅ ALINHADO: Dockerfile v1.5.1; post-create v1.2.3; post-start v3.0.3. +// ✅ ALINHADO: local-dns-cache v1.8.1; network-control-plane-state v1.1.1. +// ✅ CORRIGIDO: caminho canônico do Network Control Plane sem o segmento `network/` espúrio. +// ✅ CORRIGIDO: versões/labels ativos do DevContainer e da imagem, antes divergentes do Dockerfile canônico. +// ✅ ENDURECIDO: hooks recuperam override runtime stale/ilegível usando o script canônico existente. +// ✅ MANTIDO: DNS default-on fail-safe; proxy local opt-in; benchmarks longos fora do boot. +// +// CHANGELOG v5.9.0 (2026-05-20): +// 🌐 DEFAULT-ON DNS + POST-START 3.0.2 + CONTROL PLANE STATE SYNC +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: package.json v1.1.3 +// ✅ ALINHADO: Makefile v4.3.0 +// ✅ ALINHADO: post-create v1.2.2 +// ✅ ALINHADO: post-start v3.0.2 +// ✅ ALINHADO: post-attach v5.9.0 +// ✅ ALINHADO: healthcheck v3.0.0 +// ✅ ALINHADO: sync-local-auth v2.0.0 +// ✅ ALINHADO: network-control-plane-state v1.1.0 +// ✅ ALINHADO: github-api-route-fix v1.9.1 +// ✅ ALINHADO: local-dns-cache v1.8.0 +// ✅ ALINHADO: local-copilot-proxy v1.3.1 +// ✅ ALINHADO: github-copilot-network-manager v1.6.1 +// ✅ ALINHADO: copilot-route-advisor v1.1.0 +// ✅ ALINHADO: endpoints.github-copilot.tsv v1.2.0 +// ✅ CORRIGIDO: post-start agora usa action=start para o manager, gerando +// snapshot runtime bounded em vez de apenas recomendação stale. +// ✅ CORRIGIDO: DNS local default-on com prova forte antes de governar resolv.conf. +// ✅ ADICIONADO: knobs v1.8.0 para Docker embedded DNS split-horizon, warmup, +// ranking sem benchmark no boot, stale detection e probe forte. +// ✅ ADICIONADO: network-control-plane-state como agregador passivo pós-start. +// ✅ ADICIONADO: separação explícita entre summary runtime e action.summary. +// ✅ MANTIDO: proxy local desligado por padrão; nenhum HTTP(S)_PROXY global. +// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece bounded. +// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.9.0. +// +// CHANGELOG v5.8.0 (2026-05-19): +// 🧭 CANONICAL PATH REALIGNMENT + CONTROL PLANE SYNC +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-create v1.1.0 +// ✅ ALINHADO: post-start v2.8.1 +// ✅ ALINHADO: post-attach v5.7.1 +// ✅ ALINHADO: github-api-route-fix v1.8.6 +// ✅ ALINHADO: local-dns-cache v1.5.3 +// ✅ ALINHADO: local-copilot-proxy v1.2.3 +// ✅ ALINHADO: github-copilot-network-manager v1.5.3 +// ✅ ALINHADO: copilot-route-advisor v1.0.1 +// ✅ ALINHADO: nss-gatekeeper v2.1.2 +// ✅ CORRIGIDO: endpoint registry aponta para .devcontainer/scripts/network/ +// ✅ CORRIGIDO: aliases DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY(_FILE) +// ✅ CORRIGIDO: remoção do override DEVCONTAINER_COPILOT_PROBE_ENDPOINTS +// para permitir que o registry seja a fonte de verdade. +// ✅ CORRIGIDO: knobs do local-copilot-proxy para nomes realmente consumidos +// pelo script v1.2.3. +// ✅ ADICIONADO: metadados DEVCONTAINER_VERSION / CONTROL_PLANE_GENERATION. +// ✅ ADICIONADO: limites explícitos de consumo do endpoint registry. +// ✅ MANTIDO: benchmark prolongado manual/opt-in; boot permanece recommend/quick. +// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global. +// ✅ MANTIDO: DNS cache local em auto/ranked com fail-closed. +// ✅ ATUALIZADO: labels runtime para devcontainer.version=5.8.0. +// +// // CHANGELOG v5.7.0 (2026-05-17): +// 🧭 NETWORK CONTROL PLANE + SAFE BENCHMARK INTEGRATION +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: package.json v1.1.0 +// ✅ ALINHADO: Makefile v4.2.0 +// ✅ ALINHADO: post-create v1.0.4 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: post-attach v5.7.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ ADICIONADO: defaults formais para benchmark prolongado manual/opt-in +// ✅ ADICIONADO: recommendation artifacts e policy bridge sem aplicação automática +// ✅ ADICIONADO: endpoint registry oficial para GitHub/Copilot +// ✅ ADICIONADO: superfície Copilot ampliada (origin-tracker + telemetry) +// ✅ MANTIDO: proxy local desligado por padrão; sem HTTPS_PROXY/HTTP_PROXY global +// ✅ MANTIDO: benchmark longo fora do boot; post-start apenas quick/recommend +// ✅ ATUALIZADO: build.args.VERSION e labels para Dockerfile 1.4.2 / devcontainer 5.7.0 +// +// CHANGELOG v5.6.0 (2026-05-16): +// 🌐 DNS CACHE PROMOTION + NETWORK HARDENING +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-dns-cache v1.5.1 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ PROMOVIDO: DNS cache local habilitado por padrão em modo auto/ranked +// ✅ ADICIONADO: hardening de dnsmasq start/repair/ownership/port-check +// ✅ ADICIONADO: thresholds e locks explícitos para GitHub/Copilot manager +// ✅ ADICIONADO: verificação estrita de IP aplicado no route-fix +// ✅ MANTIDO: proxy local desligado por padrão até teste controlado posterior +// ✅ CORRIGIDO: DEVCONTAINER_MAKE_TIMEOUT=30, validado após PM2 warmup +// +// CHANGELOG v5.5.0 (2026-05-16): +// 🌐 NETWORK ARCHITECTURE SYNC (GITHUB/COPILOT) +// ✅ ALINHADO: Dockerfile v1.4.2 +// ✅ ALINHADO: post-start v2.8.0 +// ✅ ALINHADO: github-api-route-fix v1.8.4 +// ✅ ALINHADO: local-dns-cache v1.2.0 +// ✅ ALINHADO: local-copilot-proxy v1.2.2 +// ✅ ALINHADO: github-copilot-network-manager v1.5.0 +// ✅ ADICIONADO: Copilot Network Manager habilitado por padrão +// ✅ ADICIONADO: DNS cache local e proxy local como infraestrutura opt-in +// ✅ CORRIGIDO: CODEX_HOME aponta para ${containerWorkspaceFolder}/.codex +// ✅ CORRIGIDO: lifecycle hooks quotados e chamados por bash explicitamente +// ✅ CORRIGIDO: build.args.VERSION e labels para 1.4.0 / devcontainer 5.5.0 +// +// CHANGELOG v5.4.0 (2026-05-15): +// 🔧 DOCKERFILE/NSS SYNC (CANONICAL ALIGNMENT) +// ✅ ALINHADO: Dockerfile.canonical.v1.3 +// ✅ ALINHADO: nss-gatekeeper canonical v2.0.0 +// ✅ ALINHADO: post-start v2.5.0 +// ✅ ALINHADO: post-attach v5.4.0 +// ✅ CORRIGIDO: build.args.VERSION 1.0 → 1.3 +// ✅ CORRIGIDO: LD_PRELOAD absoluto/canônico em containerEnv + remoteEnv +// ✅ CORRIGIDO: portsAttributes wildcard → otherPortsAttributes oficial +// ✅ ADICIONADO: GitHub.copilot explicitamente junto de GitHub.copilot-chat +// ✅ ADICIONADO: overrideCommand=false para honrar CMD/ENTRYPOINT da imagem +// ✅ ADICIONADO: labels runtime devcontainer.version e image.version +// +// CHANGELOG v5.3 (2026-02-03): +// 🔧 SSH FORWARDING MIGRATION (BREAKING CHANGE - FIX CRÍTICO) +// ❌ REMOVIDO: Mount manual de SSH socket (causava erro fatal) +// ❌ REMOVIDO: SSH_AUTH_SOCK hardcoded em remoteEnv +// ❌ REMOVIDO: DEVCONTAINER_SECRET_SURFACE_SSH (redundante) +// ❌ REMOVIDO: DEVCONTAINER_SSH_AGENT_ALLOWED (containerEnv) +// ✅ ADICIONADO: VS Code native SSH forwarding (automático) +// ✅ ADICIONADO: Documentação completa da migração +// ✅ RESOLVIDO: Container agora inicia COM ou SEM SSH agent +// +// REFERÊNCIAS: +// • .devcontainer/MIGRATION_SSH_V5.3.md (documentação completa) +// • .devcontainer/TROUBLESHOOTING_SSH.md (guia de debug) +// • DEVCONTAINER_BUILD_ANALYSIS.md (análise técnica) +// +// CHANGELOG v5.2 (2026-02-02): +// ✅ Sincronização de versão (3.4 → 5.2) +// ✅ Features consolidadas (removidas duplicações) +// ✅ Extensions agrupadas por categoria +// ✅ Scrollback otimizado (20000 → 10000) +// ✅ Documentação de segurança melhorada (--group-add=docker) +// ✅ remoteEnv consolidado (removidas duplicações) +// ============================================================================ +{ + // ============================================================ + // BUILD CONFIGURATION (v5.9.1 — Dockerfile v1.5.1 sync) + // ============================================================ + "build": { + "args": { + "BUILD_DATE": "${localEnv:BUILD_DATE}", + "BUILD_ENV": "dev", + // 🔐 SINCRONIZAÇÃO DOCKER + "DOCKER_GID": "${localEnv:DOCKER_GID}", + "IMAGE_NAME": "chatgpt-docker-puppeteer", + "IMAGE_VENDOR": "Yuri", + "PROJECT_NAME": "${localWorkspaceFolderBasename}", + // --- O APERTO DE MÃO (Identidade) --- + // REMOTE_USER: Sincroniza remoteUser com Dockerfile (torna imagem dinâmica) + // Dockerfile usa: ENV USER_NAME=${REMOTE_USER} + // NOTA: ${containerUser} NÃO existe como variável built-in do DevContainers. + // Usar valor literal que corresponde ao remoteUser (linha ~998) + "REMOTE_USER": "node", + "VCS_REF": "${localEnv:GIT_COMMIT}", + // --- METADATA & VERSIONING --- + "VERSION": "1.5.1", + // --- NETWORK CONTROL PLANE DEFAULTS --- + // Usados apenas como defaults de imagem; benchmark longo permanece manual. + "NETWORK_BENCHMARK_DURATION_SECONDS": "${localEnv:NETWORK_BENCHMARK_DURATION_SECONDS:600}", + "NETWORK_BENCHMARK_INTERVAL_SECONDS": "${localEnv:NETWORK_BENCHMARK_INTERVAL_SECONDS:10}", + "NETWORK_RECOMMENDATION_TTL_SECONDS": "${localEnv:NETWORK_RECOMMENDATION_TTL_SECONDS:86400}" + }, + "context": "..", + "dockerfile": "Dockerfile" + }, + // ============================================================ + // CONTAINER ENVIRONMENT VARIABLES (v5.9.1 — NSS/Dockerfile/network sync) + // ============================================================ + "containerEnv": { + // canonical shell contract mirrored from /etc/profile.d/00-runtime.sh + // ensures non‑login terminals inherit the same semantic hints as + // interactive logins. purely informational, used by helpers and tests. + "SHELL_CANONICAL": "bash", + "CANONICAL_SHELL": "bash", + "INSTRUMENTAL_SHELLS": "pwsh", + "DEVCONTAINER_MAKE_TIMEOUT": "30", + + "APP_ENV": "dev", + "APP_NAME": "chatgpt-docker-puppeteer", + "APP_ROLE": "agent", + "DEVCONTAINER_VERSION": "5.9.1", + "DEVCONTAINER_CONTROL_PLANE_GENERATION": "network-control-plane-v5.9.1", + "DEVCONTAINER_CANONICAL_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_POST_START_SCRIPT_VERSION_EXPECTED": "3.0.3", + "DEVCONTAINER_POST_ATTACH_SCRIPT_VERSION_EXPECTED": "5.9.1", + "DEVCONTAINER_HEALTHCHECK_SCRIPT_VERSION_EXPECTED": "3.0.1", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT_VERSION_EXPECTED": "1.1.1", + "DEVCONTAINER_SYNC_LOCAL_AUTH_SCRIPT_VERSION_EXPECTED": "2.0.0", + // CHOKIDAR_USEPOLLING e CHOKIDAR_INTERVAL removidos em v5.4.0: + // O workspace é montado como ext4 (inotify nativo funciona). + // Vite já configura polling explicitamente em vite.config.js (watch.usePolling: true). + // Os watchers do servidor usam fs.watch() nativo — não dependem de chokidar. + // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-001] + "DEBUG": "puppeteer:*,agent:*", + "DEVCONTAINER_DOCKER_ENABLED": "true", + "DOCKER_BUILDKIT": "1", + "DOCKER_HOST_ACCESS": "true", + "DOCKER_SECURITY_LEVEL": "host-root-equivalent", + // Canonical NSS artifact directory. + // Used by profile.d + nss-gatekeeper; safe as plain metadata in containerEnv. + "DEVCONTAINER_NSS_DIR": "/tmp/devcontainer-nss", + // ========================================================= + // NETWORK RESILIENCE — GitHub/Copilot route manager + // --------------------------------------------------------- + // Smart route selector for api.github.com, delegated by + // post-start.sh to .devcontainer/scripts/network/github-api-route-fix.sh. + // It repairs ISP/DNS edge failures without changing Windows/host network. + // ========================================================= + "DEVCONTAINER_ENABLE_GITHUB_API_ROUTE_FIX": "true", + "DEVCONTAINER_GITHUB_API_HOST": "api.github.com", + "DEVCONTAINER_GITHUB_API_FUNCTIONALITY_PROFILE": "copilot", + "DEVCONTAINER_GITHUB_API_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_GITHUB_API_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_GITHUB_API_BENCHMARK_INCLUDE_CANDIDATES": "true", + "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_CACHE": "true", + "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMEND_MIN_SAMPLES": "5", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", + "DEVCONTAINER_GITHUB_API_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", + "DEVCONTAINER_GITHUB_API_BENCHMARK_RECOMMENDATION_TTL_SECONDS": "86400", + "DEVCONTAINER_GITHUB_API_ROUTE_PROXY_MODE": "auto", + "DEVCONTAINER_GITHUB_API_MIN_SCORE": "85", + "DEVCONTAINER_GITHUB_API_MAX_CANDIDATES": "16", + "DEVCONTAINER_GITHUB_API_PARALLEL_PROBES": "true", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_ENABLED": "true", + "DEVCONTAINER_GITHUB_API_ENABLE_IPV6": "false", + "DEVCONTAINER_GITHUB_API_OPENSSL_PREFLIGHT": "false", + "DEVCONTAINER_GITHUB_API_STRICT_VERIFY_EXPECTED_IP": "true", + "DEVCONTAINER_GITHUB_API_CACHE_LOCK_WAIT_SECONDS": "10", + "DEVCONTAINER_GITHUB_API_HOSTS_LOCK_WAIT_SECONDS": "15", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_ENTRIES": "128", + "DEVCONTAINER_GITHUB_API_ROUTE_CACHE_MAX_AGE_SECONDS": "86400", + "DEVCONTAINER_GITHUB_API_ACTION_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_GITHUB_API_BENCHMARK_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_GITHUB_API_ROUTE_CONNECT_TIMEOUT": "4", + "DEVCONTAINER_GITHUB_API_ROUTE_MAX_TIME": "12", + "DEVCONTAINER_GITHUB_API_VERSION": "2022-11-28", + "DEVCONTAINER_GITHUB_API_ROLLBACK_ON_VERIFY_FAILURE": "true", + "DEVCONTAINER_GITHUB_API_OPTIMIZE_WHEN_CURRENT_OK": "false", + "DEVCONTAINER_GITHUB_API_HYSTERESIS_SCORE_MARGIN": "5000", + "DEVCONTAINER_GITHUB_API_RECENT_FAILURE_HARD_BLOCK_SECONDS": "0", + "DEVCONTAINER_SKIP_GITHUB_API_PROBES_AFTER_ROUTE_FIX": "true", + + // ========================================================= + // NETWORK RESILIENCE — Modular GitHub/Copilot layer (v5.5.0) + // --------------------------------------------------------- + // Camada 1: github-api-route-fix.sh corrige ativamente apenas api.github.com. + // Camada 2: github-copilot-network-manager.sh agrega route-fix + probes. + // Camada 3: local-dns-cache.sh e local-copilot-proxy.sh existem como + // infraestrutura opt-in, não ativada automaticamente. + // + // Política: + // • ativo por padrão: manager + route-fix de api.github.com; + // • opt-in: cache DNS local e proxy HTTP CONNECT local; + // • IPv6 para api.github.com permanece off até haver AAAA real funcional. + // ========================================================= + "DEVCONTAINER_ENABLE_COPILOT_NETWORK_MANAGER": "true", + "DEVCONTAINER_COPILOT_NETWORK_MANAGER_MODE": "active", + "DEVCONTAINER_COPILOT_NETWORK_MANAGER_POST_START_ACTION": "start", + "DEVCONTAINER_COPILOT_TRANSPORT_PROFILE": "auto", + "DEVCONTAINER_POST_START_APPLY_TRANSPORT_RECOMMENDATION": "false", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_COPILOT_NETWORK_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_COPILOT_NETWORK_RECOMMENDATION_TTL_SECONDS": "86400", + // Endpoint registry canônico: + // • o arquivo vive junto dos scripts de rede em .devcontainer/scripts/network/ + // • ambos os aliases são definidos porque post-start, post-attach, + // github-copilot-network-manager e post-create aceitam nomes diferentes. + // • NÃO definir DEVCONTAINER_COPILOT_PROBE_ENDPOINTS aqui; quando presente, + // ele sobrescreve o registry e impede que a allowlist TSV governe os probes. + "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_COPILOT_ENDPOINT_REGISTRY_FILE": "${containerWorkspaceFolder}/.devcontainer/scripts/network/endpoints.github-copilot.tsv", + "DEVCONTAINER_COPILOT_USE_ENDPOINT_REGISTRY": "true", + "DEVCONTAINER_POST_START_ENDPOINT_REGISTRY_MAX_ROWS": "64", + "DEVCONTAINER_COPILOT_MANAGER_MAX_ENDPOINTS": "64", + "DEVCONTAINER_COPILOT_MANAGER_RUN_API_ROUTE_FIX": "true", + "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_DEGRADED": "false", + "DEVCONTAINER_COPILOT_MANAGER_SUBSCRIPT_TIMEOUT_SECONDS": "90", + "DEVCONTAINER_COPILOT_WARN_TOTAL_MS": "1500", + "DEVCONTAINER_COPILOT_PROBE_CONNECT_TIMEOUT": "4", + "DEVCONTAINER_COPILOT_PROBE_MAX_TIME": "12", + "DEVCONTAINER_COPILOT_PROBE_IP_FAMILY": "4", + "DEVCONTAINER_COPILOT_PROBE_PROXY_MODE": "auto", + "DEVCONTAINER_COPILOT_PROBE_PARALLEL": "true", + "DEVCONTAINER_COPILOT_NETWORK_LOCK_WAIT_SECONDS": "30", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_LOCK_WAIT_SECONDS": "10", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_ENABLED": "true", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_MAX_LINES": "2000", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_WINDOW": "40", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_SLOW_THRESHOLD": "1500", + "DEVCONTAINER_COPILOT_NETWORK_HISTORY_FAIL_THRESHOLD": "3", + "DEVCONTAINER_COPILOT_MANAGER_PARALLEL_PROBES": "true", + "DEVCONTAINER_COPILOT_MANAGER_MARK_UNSTABLE_AS_DEGRADED": "true", + "DEVCONTAINER_COPILOT_MANAGER_FAIL_ON_UNSTABLE": "false", + "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_ENDPOINTS": "false", + "DEVCONTAINER_COPILOT_MANAGER_ALLOW_CUSTOM_GITHUB_API_HOST": "false", + // DEVCONTAINER_COPILOT_PROBE_ENDPOINTS deliberadamente omitido. + // Fallbacks permanecem nos scripts; a fonte primária agora é o TSV canônico. + + // DNS cache local: default-on em modo auto/ranked com local-dns-cache v1.8.0. + // O script só escreve /etc/resolv.conf depois de prova local forte + // (dig/drill/nslookup), preserva search/domain e mantém split-horizon + // para Docker embedded DNS quando 127.0.0.11 existir no runtime. + "DEVCONTAINER_ENABLE_LOCAL_DNS_CACHE": "true", + "DEVCONTAINER_LOCAL_DNS_CACHE_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_POST_START_ACTION": "start", + "DEVCONTAINER_LOCAL_DNS_CACHE_ACTION": "start", + "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_OFF": "true", + "DEVCONTAINER_POST_START_DNS_BASELINE_ON_CACHE_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_BIND_ADDRESS": "127.0.0.1", + "DEVCONTAINER_LOCAL_DNS_BIND_PORT": "53", + "DEVCONTAINER_LOCAL_DNS_UPSTREAM_SELECTION": "ranked", + "DEVCONTAINER_LOCAL_DNS_UPSTREAMS": "1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112", + "DEVCONTAINER_LOCAL_DNS_BENCHMARK_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com", + "DEVCONTAINER_LOCAL_DNS_START_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_WRITE_RESOLV_CONF": "true", + "DEVCONTAINER_LOCAL_DNS_REPAIR_ON_PROBE_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_VALIDATE_CONFIG": "true", + "DEVCONTAINER_LOCAL_DNS_STRICT_PORT_CHECK": "true", + "DEVCONTAINER_LOCAL_DNS_TAKEOVER_STALE_DNSMASQ": "true", + "DEVCONTAINER_LOCAL_DNS_STOP_BY_SOCKET_OWNER": "true", + "DEVCONTAINER_LOCAL_DNS_STOP_WAIT_MS": "2000", + "DEVCONTAINER_LOCAL_DNS_FORWARD_MAX": "150", + "DEVCONTAINER_LOCAL_DNS_CACHE_SIZE": "10000", + "DEVCONTAINER_LOCAL_DNS_MIN_CACHE_TTL": "0", + "DEVCONTAINER_LOCAL_DNS_MAX_CACHE_TTL": "300", + "DEVCONTAINER_LOCAL_DNS_NEG_TTL": "30", + "DEVCONTAINER_LOCAL_DNS_RESOLV_OPTIONS": "timeout:1 attempts:2 rotate", + "DEVCONTAINER_LOCAL_DNS_READ_ETC_HOSTS": "false", + "DEVCONTAINER_LOCAL_DNS_LOG_QUERIES": "false", + "DEVCONTAINER_LOCAL_DNS_ENABLE_IPV6_UPSTREAMS": "false", + "DEVCONTAINER_LOCAL_DNS_ALL_SERVERS": "false", + "DEVCONTAINER_LOCAL_DNS_STRICT_ORDER": "false", + "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE": "false", + "DEVCONTAINER_LOCAL_DNS_USE_STALE_CACHE_TTL": "60", + "DEVCONTAINER_LOCAL_DNS_ACTION_UPDATE_RUNTIME_SUMMARY": "false", + "DEVCONTAINER_LOCAL_DNS_REQUIRE_PROVEN_LOCAL_PROBE_FOR_RESOLV_CONF": "true", + "DEVCONTAINER_LOCAL_DNS_ALLOW_PROCESS_ONLY_LOCAL_PROBE": "false", + "DEVCONTAINER_LOCAL_DNS_REPAIR_STALE_PIDFILE": "true", + "DEVCONTAINER_LOCAL_DNS_PRESERVE_RESOLV_SEARCH": "true", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_MODE": "auto", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_RESOLVER": "127.0.0.11", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_UNQUALIFIED": "true", + "DEVCONTAINER_LOCAL_DNS_DOCKER_EMBEDDED_ROUTE_SEARCH_DOMAINS": "true", + "DEVCONTAINER_LOCAL_DNS_REBIND_OK_DOCKER_DOMAINS": "true", + "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_FAILURE": "true", + "DEVCONTAINER_LOCAL_DNS_RESTORE_RESOLV_CONF_ON_STOP": "true", + "DEVCONTAINER_LOCAL_DNS_WARMUP": "true", + "DEVCONTAINER_LOCAL_DNS_WARMUP_HOSTS": "api.github.com github.com copilot-proxy.githubusercontent.com api.githubcopilot.com default.exp-tas.com origin-tracker.githubusercontent.com", + "DEVCONTAINER_LOCAL_DNS_WARMUP_MAX_HOSTS": "8", + "DEVCONTAINER_LOCAL_DNS_WARMUP_RECORD_TYPES": "A", + "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_ON_START": "false", + "DEVCONTAINER_LOCAL_DNS_FORCE_REBENCHMARK": "false", + "DEVCONTAINER_LOCAL_DNS_RANKING_MAX_AGE_SECONDS": "86400", + "DEVCONTAINER_LOCAL_DNS_REBENCHMARK_MIN_SECONDS": "900", + "DEVCONTAINER_LOCAL_DNS_RANKING_HYSTERESIS_SCORE_MARGIN": "5000", + "DEVCONTAINER_LOCAL_DNS_STATUS_STALE_MAX_SECONDS": "300", + "DEVCONTAINER_LOCAL_DNS_FAST_RETRY": "false", + + // Proxy HTTP CONNECT local: disponível na imagem v1.5.1, mas desligado por padrão. + // Para habilitar de forma explícita: DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY=true + // e DEVCONTAINER_COPILOT_PROXY_MODE=local. + "DEVCONTAINER_ENABLE_LOCAL_COPILOT_PROXY": "false", + "DEVCONTAINER_COPILOT_PROXY_MODE": "off", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_MODE": "off", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_HOST": "127.0.0.1", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_PORT": "3128", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_APPLY_PROFILE": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_STATUS_STRICT": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_USE_ENDPOINT_REGISTRY": "true", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_CUSTOM_PROBE_URLS": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_ALLOW_NON_LOOPBACK": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_CONNECT_PORTS": "443", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_REMOVE_PROFILE_ON_STOP": "true", + "DEVCONTAINER_POST_START_SOURCE_LOCAL_PROXY_ENV": "false", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_DURATION_SECONDS": "600", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_INTERVAL_SECONDS": "10", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_SAMPLES": "0", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_SAMPLES": "5", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MIN_IMPROVEMENT_PERCENT": "25", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_BENCHMARK_MAX_FAIL_RATE_PERCENT": "10", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_START_PROXY": "true", + "DEVCONTAINER_LOCAL_COPILOT_PROXY_COMPARE_KEEP_PROXY": "true", + "DEVCONTAINER_POST_START_OBSERVE_LOCAL_PROXY_STATUS": "true", + + // Evita duplicar probes quando o manager v1.1+ já rodou com sucesso. + "DEVCONTAINER_POST_START_LEGACY_PROBES_AFTER_MANAGER": "false", + "DEVCONTAINER_POST_START_SUBSCRIPT_TIMEOUT_SECONDS": "90", + "DEVCONTAINER_ENABLE_NETWORK_CONTROL_PLANE_STATE": "true", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_POST_START_ACTION": "summary", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_TIMEOUT_SECONDS": "10", + "DEVCONTAINER_NETWORK_CONTROL_PLANE_SCRIPT": "${containerWorkspaceFolder}/.devcontainer/scripts/network-control-plane-state.sh", + "DEVCONTAINER_HEALTHCHECK_MIN_NODE_MAJOR": "24", + // Safe to globalize as long as NSS_WRAPPER_* points at files that always exist. + // This keeps base container processes (including VS Code bootstrap paths) + // on a safe, always-readable NSS baseline before profile hooks refine it. + "DEVCONTAINER_NSS_WRAPPER_LIB": "/usr/local/lib/devcontainer/libnss_wrapper.so", + "LD_PRELOAD": "/usr/local/lib/devcontainer/libnss_wrapper.so", + // Bootstrap fallback must point to stable files. /tmp artifacts are runtime + // only and may not exist yet when VS Code issues early exec/user commands. + // The gatekeeper/profile path can still override these to /tmp later. + "NSS_WRAPPER_PASSWD": "/etc/passwd", + "NSS_WRAPPER_GROUP": "/etc/group", + // Canonical tooling metadata exported by the image for wrappers/checkers. + "JSONC_PARSER_MODULE_PATH": "/usr/local/share/npm-global/lib/node_modules/jsonc-parser", + // ========================================================= + // SSH VARIABLES (v5.3 - CLEANUP) + // --------------------------------------------------------- + // REMOVIDO: "DEVCONTAINER_SSH_AGENT_ALLOWED": "true" + // REMOVIDO: "SSH_AUTH_SOCK": "/ssh-agent" + // + // JUSTIFICATIVA: + // • Variáveis eram usadas com mount manual (removido) + // • VS Code native forwarding não requer essas variáveis + // • Simplificação da configuração + // ========================================================= + // Nota estrutural: + // • `containerEnv` deve complementar a imagem, não duplicar defaults + // já fixados no Dockerfile (ex.: NODE_ENV, LOG_LEVEL, LANG). + // • `FORCE_COLOR` NÃO deve ser global aqui: vários shells exportam + // `NO_COLOR`, e a combinação gera warning no startup do Node. + // • Forçar cor deve ficar em env por processo (PM2, testes, helpers). + // ------------------------------------------------------------ + "NPM_CONFIG_CACHE": "/home/node/.npm", + // ------------------------------------------------------------ + // RUNTIME TUNING (Performance & Noise) + // ------------------------------------------------------------ + // NOTA: NODE_OPTIONS removido - cada configuração de launch + // define seu próprio --max-old-space-size em runtimeArgs + // para evitar duplicação e conflitos com o debugger + "PM2_HOME": "/home/node/.pm2", + // Hint semântico (documental, não funcional) + // • Indica que há uma camada proxy entre container e browser + "PUPPETEER_ARCHITECTURE": "external-browser-via-proxy", + // Timeout de conexão CDP (milissegundos) + // • Protege contra stalls de rede + // • NÃO implica que Chrome deva estar ativo + "PUPPETEER_CONNECT_TIMEOUT": "30000", + // ------------------------------------------------------------ + // PUPPETEER & CHROME — INTEGRAÇÃO EXTERNA (CONTRATO CANÔNICO) + // ------------------------------------------------------------ + // + // MODELO FÍSICO REAL (IMPORTANTE): + // + // • Windows Host + // - Chrome REAL (backend de automação) + // - Expõe Chrome DevTools Protocol (CDP) na porta 9225 + // - Bind: 0.0.0.0 (acessível via host.docker.internal) + // - NUNCA é acessado diretamente pelo Puppeteer + // + // • DevContainer + // - Chrome Proxy Service roda AQUI (gerenciado por PM2) + // - Escuta: localhost:9224 (frontend canônico para Puppeteer) + // - Encaminha: host.docker.internal:9225 (backend para Chrome Windows) + // - Puppeteer conecta SEMPRE em localhost:9224 + // + // CONSEQUÊNCIAS DO CONTRATO: + // + // • Puppeteer conecta EXCLUSIVAMENTE em localhost:9224 + // • Proxy encaminha para host.docker.internal:9225 (Chrome no Windows) + // • Isolamento completo: Puppeteer NÃO conhece o host nem a porta 9225 + // • Chrome externo é FUNDAMENTAL para operações LLM via Puppeteer + // • A ausência de Chrome durante build/attach é ESTADO VÁLIDO + // • Chrome será iniciado sob demanda quando operações LLM forem acionadas + // • Nenhum componente de infraestrutura inicia Chrome automaticamente + // + // ------------------------------------------------------------ + "PUPPETEER_MODE": "connect", + // Puppeteer NUNCA deve baixar Chromium automaticamente + // • Chrome externo é primário + // • Chromium local é fallback técnico (imagem-level) + "PUPPETEER_SKIP_DOWNLOAD": "true", + "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD": "true", + "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium", + // ------------------------------------------------------------ + // PUPPETEER — ENDPOINT CANÔNICO (CONTRATO DE FRONTEIRA) + // ------------------------------------------------------------ + // + // TOPOLOGIA FÍSICA REAL: + // ------------------------------------------------------------ + // + // ┌─────────────────────────────────────┐ + // │ DevContainer (Docker) │ + // │ │ + // │ ┌──────────────────────────────┐ │ + // │ │ Puppeteer / Node.js │ │ + // │ │ (conecta localhost:9224) │ │ + // │ └────────────┬─────────────────┘ │ + // │ │ │ + // │ ┌────────────▼─────────────────┐ │ + // │ │ Chrome Proxy Service (PM2) │ │ + // │ │ (bind 0.0.0.0:9224) │ │ + // │ │ • Reescreve Host: headers │ │ + // │ │ • Reescreve WebSocket URLs │ │ + // │ └────────────┬─────────────────┘ │ + // │ │ host.docker.internal│ + // └───────────────┼─────────────────────┘ + // │ + // ┌───────────────▼─────────────────────┐ + // │ Windows Host │ + // │ │ + // │ ┌──────────────────────────────┐ │ + // │ │ Chrome (9225, bind 0.0.0.0) │ │ + // │ │ --remote-debugging-port=9225 │ │ + // │ └──────────────────────────────┘ │ + // │ │ + // └─────────────────────────────────────┘ + // + // VISÃO DO PUPPETEER: + // ------------------------------------------------------------ + // • Conecta SEMPRE em localhost:9224 (proxy no mesmo container) + // • NÃO conhece a porta 9225 + // • NÃO conhece o Windows Host + // • NÃO sabe que há um proxy intermediário + // + // VISÃO DO CHROME PROXY: + // ------------------------------------------------------------ + // • Escuta em 0.0.0.0:9224 (acessível dentro do container) + // • Encaminha para host.docker.internal:9225 (Chrome no Windows) + // • Reescreve URLs para tornar conexão transparente + // + // CONTRATO (INVIOLÁVEL): + // ------------------------------------------------------------ + // • Puppeteer → localhost:9224 (mesma máquina) + // • Proxy → host.docker.internal:9225 (máquina remota) + // • Violação quebra isolamento arquitetural + // ------------------------------------------------------------ + "PUPPETEER_WS_ENDPOINT": "http://localhost:9224", + // ============================================================ + // LOCALE & DOCKER ENGINE + // ============================================================ + // Variáveis de instrumentação (estabilidade de telemetria do servidor) + "VSCODE_INSTRUMENTATION": "true", + "VSCODE_SERVER_EXPECTED": "true", + "VSCODE_SERVER_ROLE": "instrumentation", + // ============================================================ + // LSP LOCAL (legado preservado, sempre desligado por padrão) + // ============================================================ + // O editor usa o TSServer/LSP nativo do TypeScript 7. O daemon MCP local + // só pode ser habilitado explicitamente em um processo isolado. + "LSP_ENABLED": "false", + "LSP_MUTATIONS_ENABLED": "false", + // Timeout por operação LSP (ms). O daemon usa este valor como fallback; + // cada chamada pode sobrescrever via options.timeoutMs. + "LSP_TOOL_TIMEOUT_MS": "15000", + // Número máximo de resultados retornados por operações de busca (references, + // workspace_symbols, diagnostics, completion). Reduzir melhora latência + // em projetos grandes; aumentar melhora completude de busca. + "LSP_MAX_RESULTS": "200" + // ============================================================ + // FILE WATCHING & VS CODE INTERNALS + // ============================================================ + // WATCHPACK_POLLING removido em v5.4.0: + // Projeto usa Vite (não webpack). Variável não tem efeito detectável no projeto. + // Referência: DEVCONTAINER_ARCHITECTURE.md [DEC-002] + }, + // ============================================================ + // VS CODE CUSTOMIZATIONS + // ============================================================ + "customizations": { + "vscode": { + "extensions": [ + "TypeScriptTeam.native-preview", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-azuretools.vscode-containers", + "ms-vscode.makefile-tools", + "timonwong.shellcheck", + "redhat.vscode-yaml", + "EditorConfig.EditorConfig", + "Vue.volar", + "github.vscode-github-actions", + "DavidAnson.vscode-markdownlint" + ], + "settings": { + // TypeScript 7.0.x is GA. VS Code 1.134 still ships the legacy Node/tsserver.js client, so the external + // official LSP client remains required for now under its historical Marketplace ID `TypeScriptTeam.native-preview`. + // Its bundled server is stable TS7 (`tsc --lsp`). Remove the external client once the same client is bundled by VS Code. + // Toggle this flag to false only for a deliberate fallback to the built-in Node/tsserver.js service. + "js/ts.experimental.useTsgo": true, + // O cliente TS7 nativo é Go: esta é a chave efetiva de GOMEMLIMIT. O antigo `js/ts.tsserver.maxMemory` + // configura apenas o fallback Node/tsserver e não limita `tsc --lsp`. + "js/ts.server.goMemLimit": "1024MiB", + // O cliente LSP externo atual (ID histórico `native-preview`) tem trace LSP verboso por default; desabilitamos para não reter tráfego no + // Extension Host nem pagar serialização/logging em toda interação JS/TS. + "js/ts.trace.server": "off", + // In DevContainer, Codex must run in-container instead of being redirected to host-side WSL. + "chatgpt.runCodexInWindowsSubsystemForLinux": false, + "debug.javascript.autoAttachFilter": "onlyWithFlag", + "editor.bracketPairColorization.enabled": true, + //"editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" + }, + // ======================================================== + // EDITOR & FORMATTING (controle humano explícito) + // ======================================================== + "editor.formatOnSave": true, + "editor.guides.indentation": true, + "editor.minimap.enabled": true, + "editor.minimap.maxColumn": 80, + "editor.minimap.renderCharacters": false, + "editor.renderWhitespace": "boundary", + "editor.smoothScrolling": true, + // ======================================================== + // EDITOR UX (Acessibilidade do Código) + // ======================================================== + "editor.stickyScroll.enabled": true, // Facilita navegar em classes longas do KERNEL + "eslint.alwaysShowStatus": true, + //"[javascript]": { + // "editor.defaultFormatter": "esbenp.prettier-vscode" + //}, + // ======================================================== + // ESLINT (fonte de verdade para qualidade) + // ======================================================== + "eslint.validate": ["javascript", "javascriptreact"], + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], + "files.associations": { + "*.env*": "dotenv", + "Makefile": "makefile" + }, + "files.autoSave": "onFocusChange", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + // ======================================================== + // FILE SYSTEM & SEARCH (A Blindagem) + // ======================================================== + "files.watcherExclude": { + "**/.cache/**": true, // NOVO: Protege contra cache do Puppeteer/NPM + "**/.claude/**": true, // NOVO: Evita indexar estados pesados da IA + "**/.git/objects/**": true, + "**/.git/refs/**": true, + "**/.git/logs/**": true, + "**/backups/**": true, // v5.4.0: exclui backups do watcher + "**/artifacts/**": true, // v5.4.0: arquivos gerados + "**/monitoring/**": true, + "**/dist/**": true, + "**/logs/**": true, + "**/node_modules/**": true, + "**/node-history/**": true, // NOVO: O histórico é para o shell, não para o editor + "**/profile/**": true, + "**/respostas/**": true, + "**/tmp/**": true + }, + // ======================================================== + // EXTENSION HOST — ESTABILIDADE DE MEMÓRIA (v5.4.0) + // O extension host com 55+ extensões pode consumir 1.7+ GB. + // Reduzir auto-save frequency e desabilitar código desnecessário + // alivia o GC do extension host e reduz chances de crash/restart. + // ======================================================== + // Reduz frequência de auto-fetch Git (causa wake-ups do extension host) + "git.autofetch": false, // v5.4.0: desativado (era true) — causa I/O periódico + "git.autofetchPeriod": 1800, // v5.4.0: se reativado, 30 min mínimo + "git.confirmSync": false, + "git.decorations.enabled": true, + "git.enableSmartCommit": true, + "github.copilot.editor.enableAutoCompletions": true, + "github.copilot.editor.iterativeFixing": true, + // ======================================================== + // GITHUB COPILOT — CONFIGURAÇÕES OTIMIZADAS + // ======================================================== + "github.copilot.enable": { + "*": true, + "jsonc": true, + "markdown": true, + "plaintext": true, + "yaml": true + }, + // ======================================================== + // JAVASCRIPT / NODE.JS (infra + backend) + // ======================================================== + "javascript.preferences.importModuleSpecifier": "relative", + "javascript.updateImportsOnFileMove.enabled": "always", + "jest.autoRun": "off", + "jest.runMode": "on-demand", + "json.validate.enable": true, + // ======================================================== + // MARKDOWN & DOCUMENTATION + // ======================================================== + "markdown.preview.breaks": true, + "markdown.preview.scrollPreviewWithEditor": true, + // ======================================================== + // REFERENCES & NAVIGATION + // ======================================================== + "references.preferredLocation": "peek", + "remote.SSH.enableAgentForwarding": true, + "remote.SSH.loglevel": "info", + "remote.SSH.useLocalServer": true, + // ======================================================== + // EXTENSÕES — ESTABILIDADE DE SESSÃO + // Previne atualizações automáticas de extensões durante sessões ativas. + // Atualizações mid-session podem causar incompatibilidade de API proposals + // (ex.: chatParticipantPrivate no Copilot Chat) e reinício do extension host. + // Para atualizar extensões, faça manualmente após encerrar a sessão. + // ======================================================== + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "search.exclude": { + "**/.cache": true, + "**/.claude": true, + "**/logs": true, + "**/node_modules": true, + "**/profile": true, + "**/respostas": true + }, + // ======================================================== + // WORKSPACE TRUST & SECURITY + // ======================================================== + "security.workspace.trust.enabled": true, + "shellcheck.enable": true, + // ======================================================== + // TELEMETRY & NOISE REDUCTION + // ======================================================== + "telemetry.telemetryLevel": "off", + "terminal.integrated.copyOnSelection": true, + "terminal.integrated.cursorBlinking": true, + // ======================================================== + // TERMINAL (UX & Performance) + // Bash = canônico | PowerShell = instrumental / IA-friendly + // ======================================================== + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.detectLocale": "off", + // "terminal.integrated.drawBoldTextInInvertedColors" removido: setting deprecado no VS Code 1.100+ + "terminal.integrated.enableMultiLinePasteWarning": "never", + // canvas: renderização estável em containers Linux (sem WebGL/GPU real). + // Consistente com .vscode/settings.json que também define "canvas". + "terminal.integrated.gpuAcceleration": "canvas", + "terminal.integrated.inheritEnv": true, + "terminal.integrated.profiles.linux": { + // ---------------------------------------------------- + // Bash — SHELL CANÔNICO (infra, automação, build) + // ---------------------------------------------------- + "bash": { + // "-l" (login shell): garante que /etc/profile.d/*.sh seja carregado + // em cada terminal integrado. Crítico para: + // • 10-gatekeeper-nss.sh → LD_PRELOAD com caminho absoluto + // (evita erros de linker em spawns intensivos de subprocesso) + // • 00-restore-env.sh → PATH correto com nvm/npm-global + // Sem -l, profile.d não recanonicaliza/refina NSS_WRAPPER_* nem + // reforça o LD_PRELOAD canônico para shells interativos. + "args": ["-l"], + "icon": "terminal-bash", + "path": "/bin/bash" + }, + // ---------------------------------------------------- + // PowerShell — INSTRUMENTAL / SEMÂNTICO / IA-FRIENDLY + // + // • Não é default + // • Não governa automação + // • Carrega contrato global automaticamente + // ---------------------------------------------------- + "pwsh": { + "args": ["-NoLogo"], + "icon": "terminal-powershell", + "path": "/usr/bin/pwsh" + } + }, + // -------------------------------------------------------- + // COMPORTAMENTO GERAL DO TERMINAL + // -------------------------------------------------------- + // ============================================================ + // TERMINAL — OPTIMIZED SETTINGS (v5.2) + // ============================================================ + // Scrollback buffer otimizado para ambiente DevContainer: + // • 10,000 linhas = ~2MB memória (scrollback típico) + // • Suficiente para debug sessions (tail -f logs, PM2, etc) + // • Redução de 50% vs. v3.4 (20,000 → 10,000) + // • Previne memory bloat em long-running containers + // + // Context: Em DevContainers, scrollback persiste enquanto o + // terminal está aberto. Com 4-5 terminais simultâneos, + // 20,000 linhas/terminal = ~40MB overhead (10,000 = ~20MB). + // + // Se precisar de mais: + // • Use 'make logs-follow' (arquivos em logs/) + // • Configure PM2 logs (ecosystem.config.js) + // • Use Docker logs (docker logs -f ) + // ============================================================ + "terminal.integrated.scrollback": 10000, + // ======================================================== + // PRETTIER (somente com configuração explícita) + // ======================================================== + // "prettier.requireConfig": true, + // ======================================================== + // TESTING (Jest + Test Explorer) + // ======================================================== + "testExplorer.useNativeTesting": false, + // ======================================================== + // YAML / JSON / SHELL (infra, contratos, scripts) + // ======================================================== + "yaml.validate": true + } + } + }, + // ============================================================ + // FEATURES + // ============================================================ + // Deliberadamente omitidas. O bloco vazio `"features": {}` faz o Dev Containers + // gerar um Dockerfile intermediário desnecessário e pode disparar warnings do + // builder sobre `ARG BASE_IMAGE` sem default. O tooling é instalado manualmente + // no Dockerfile para manter controle e evitar duplicações. + // =========================================================== + // PORT FORWARDING — CONTRATO FINAL (DENY BY DEFAULT) + // =========================================================== + // + // Este bloco NÃO é apenas configuração. + // Ele é um DOCUMENTO ARQUITETURAL VIVO. + // + // --------------------------------------------------------------------------- + // PRINCÍPIO FUNDAMENTAL + // --------------------------------------------------------------------------- + // • Política global: DENY-BY-DEFAULT + // • Nenhuma porta é exposta por acidente + // • Nenhuma inferência automática é aceitável + // • Toda porta exposta deve ter: + // - Justificativa funcional + // - Público-alvo explícito + // - Comportamento de forward declarado + // + // --------------------------------------------------------------------------- + // TOPOLOGIA FÍSICA (3 CAMADAS) + // --------------------------------------------------------------------------- + // + // WINDOWS HOST (Máquina Física) + // ├── Chrome Windows (chrome.exe, porta 9225) + // │ • Inicia: START-CHROME-SIMPLE.bat + // │ • Função: LLM Automation (ChatGPT, Gemini via Puppeteer) + // │ • Protocolo: Chrome DevTools Protocol (CDP) + // │ • Ontologia: Windows gerencia, Container conecta + // │ + // └── WSL2 (Virtual Machine Linux) + // └── Docker Desktop (Container Engine) + // └── DevContainer (Node.js Runtime) + // └── PM2 (Process Manager - 3 processos): + // │ + // ├── agente-gpt (Main Process) + // │ • Script: index.js → src/main.js + // │ • Função: Kernel + Drivers + Orchestration + NERV + // │ • Porta: NENHUMA (IPC via NERV) + // │ • Depende: Chrome Proxy (9224) + // │ + // ├── dashboard-web (Server Process) + // │ • Script: src/server/main.js + // │ • Função: HTTP + Socket.io + API REST + Telemetry + // │ • Porta: 3008 (HTTP + WebSocket) + // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) + // │ • Dependências: ZERO (autônomo) + // │ + // └── chrome-proxy (Proxy Service) + // • Script: scripts/chrome-proxy-service.js + // • Função: Transparent Proxy (HTTP + WebSocket) + // • Porta IN: 9224 (container) + // • Porta OUT: 9225 (host Windows via host.docker.internal) + // • Fluxo: Puppeteer → localhost:9224 → Chrome (9225) + // + // --------------------------------------------------------------------------- + // ARQUITETURA POR PLANOS FUNCIONAIS + // --------------------------------------------------------------------------- + // + // ┌──────────────────────────────────────────────────────────┐ + // │ UI HUMANA (Interfaces interativas) │ + // │ │ + // │ 3008 → Dashboard Principal (Mission Control) │ + // │ • Processo: dashboard-web (PM2) │ + // │ • Browser: ANY (Chrome, Firefox, Edge, Safari) │ + // │ • HTTP + Socket.io + API REST │ + // │ • Dependências: ZERO (autônomo) │ + // │ │ + // │ • Auto-forward com notificação │ + // └──────────────────────────────────────────────────────────┘ + // + // ┌──────────────────────────────────────────────────────────┐ + // │ INFRAESTRUTURA (Fronteiras Arquiteturais) │ + // │ │ + // │ 9224 → Chrome Proxy Service (Container → Windows) │ + // │ • Processo: chrome-proxy (PM2) │ + // │ • Função: Proxy transparente HTTP + WebSocket │ + // │ • IN: localhost:9224 (Puppeteer conecta aqui) │ + // │ • OUT: host.docker.internal:9225 (Chrome) │ + // │ │ + // │ 9225 → Chrome Real (Windows Host) │ + // │ • Processo: chrome.exe (Windows) │ + // │ • Inicia: START-CHROME-SIMPLE.bat │ + // │ • Função: Chrome DevTools Protocol (CDP) │ + // │ • Acesso: NUNCA direto, sempre via proxy 9224 │ + // │ │ + // │ • NÃO são UI humana │ + // │ • NÃO sofrem auto-forward │ + // │ • Representam FRONTEIRAS LÓGICAS │ + // └──────────────────────────────────────────────────────────┘ + // + // ┌──────────────────────────────────────────────────────────┐ + // │ DEBUG (OPT-IN — NUNCA NOTIFICA POR PADRÃO) │ + // │ │ + // │ 9229 → Node.js Debug (Primary - agente-gpt) │ + // │ 9230 → Node.js Debug (Fallback - dashboard-web) │ + // │ │ + // │ • Exposição silenciosa │ + // │ • Uso deliberado (--inspect flag) │ + // │ • Não notifica o operador │ + // └──────────────────────────────────────────────────────────┘ + // + // --------------------------------------------------------------------------- + // NOTAS NORMATIVAS IMPORTANTES + // --------------------------------------------------------------------------- + // + // PM2 (Process Manager): + // • Organizador geral de processos Node.js + // • Gerencia 3 processos: agente-gpt, dashboard-web, chrome-proxy + // • NÃO tem porta própria (daemon interno) + // • Web dashboard opcional (porta 9615) NÃO configurado por padrão + // + // Chrome Dual Purpose (2 usos INDEPENDENTES): + // 1. Dashboard (porta 3008): + // • Humano → ANY Browser → http://localhost:3008 → dashboard-web + // • ZERO dependência de Puppeteer + // • ZERO dependência de Chrome Windows (porta 9225) + // + // 2. LLM Automation (porta 9225): + // • agente-gpt → Puppeteer → localhost:9224 → proxy → Chrome (9225) + // • Puppeteer required + // • Chrome Windows required + // + // Fluxo de Dados LLM Automation: + // Puppeteer → localhost:9224 (proxy) → host.docker.internal:9225 (Chrome) + // + // O sistema possui Chromium local como fallback técnico de imagem, mas o fluxo + // normal de automação LLM exige Chrome externo via Chrome Proxy. O fallback local + // só deve ser ativado por decisão explícita do runtime. + // + // =========================================================== + // ============================================================================ + // PORT POLICY — SCOPE & NON-GOALS (CRITICAL) + // ---------------------------------------------------------------------------- + // Este DevContainer governa APENAS portas abertas pelo próprio container. + // + // Portas pertencentes ao HOST (Windows) — ex.: Chrome DevTools (9225) — + // estão FORA do escopo técnico deste arquivo e: + // + // • NÃO podem ser forwardadas pelo VS Code + // • NÃO devem ser declaradas em forwardPorts + // • NÃO devem aparecer em portsAttributes + // • NÃO devem ser inferidas ou "protegidas" aqui + // + // A porta 9225 (Chrome Real, Windows Host): + // • É deliberadamente EXCLUÍDA deste contrato + // • Só é acessível via Chrome Proxy Service (9224) + // • Qualquer tentativa de acesso direto é violação arquitetural + // ============================================================================ + "forwardPorts": [ + 3008, // Dashboard Principal — Mission Control (HTTP + Socket.io + API) + 5173, // Vite Dev Server — Vue Dashboard (dev only) + 9224, // Chrome Proxy Service (Container → Windows Host) + 9229, // Node.js Debug — Primary (agente-gpt --inspect) + 9230 // Node.js Debug — Fallback (dashboard-web --inspect) + ], + // ============================================================ + // RESOURCE LIMITS (Proteção do Host) + // ============================================================ + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "40gb" + }, + "mounts": [ + // ============================================================================ + // NOTA NORMATIVA — MOUNTS VS VARIÁVEIS (CONTRATO DE FASE) + // ---------------------------------------------------------------------------- + // Esta seção opera no PLANO INFRAESTRUTURAL do Docker. + // + // Regras não negociáveis: + // • O Docker resolve mounts ANTES da inicialização do container + // • O campo `target=` (container side) DEVE ser caminho literal + // - Docker NÃO expande variáveis (ex.: ${containerUserHome}, $USER) + // - Docker processamento ocorre ANTES do container existir + // • O campo `source=` (host side) PODE usar variáveis VS Code + // - VS Code expande ${localEnv:*}, ${localWorkspaceFolder} ANTES de chamar docker + // - Exemplo válido: "source=${localEnv:HOME}/data,target=/data,type=bind" + // + // Implicação arquitetural: + // • `mounts` materializam decisões já tomadas (infraestrutura concreta) + // • `containerEnv` expressa semântica dinâmica (comportamento do sistema) + // + // Portanto: + // • ✅ VÁLIDO: source=${localWorkspaceFolder}/data,target=/app/data + // • ❌ INVÁLIDO: source=/data,target=${containerWorkspaceFolder}/data + // • Usar caminhos literais em `target=` coerentes com `remoteUser: "node"` + // • Centralizar abstrações dinâmicas exclusivamente em `containerEnv` + // + // Violação deste contrato resulta em erro fatal no `docker run`. + // Referência: Docker CLI docs (--mount flag), VS Code DevContainers Variables + // ============================================================================ + // ===================================================================== + // CORE — CACHE & PERFORMANCE (XDG_CACHE_HOME) + // --------------------------------------------------------------------- + // • Cache genérico de ferramentas, linguagens e utilitários + // • Acelera rebuilds e reduz reindexação + // • Seguro para purge eventual + // ===================================================================== + "source=devcontainer-cache,target=/home/node/.cache,type=volume", + // Cache específico e pesado do Puppeteer (isolado por motivo técnico) + // • Evita downloads repetidos + // • Não deve ser compartilhado com outros caches + "source=devcontainer-puppeteer-cache,target=/home/node/.cache/puppeteer,type=volume", + // Cache dedicado do TypeScript / JavaScript Language Server + // • Melhora IntelliSense e Copilot em projetos grandes + // • Reduz reindexação após rebuild + "source=devcontainer-ts-cache,target=/home/node/.cache/typescript,type=volume", + // ===================================================================== + // NODE / PACKAGE ECOSYSTEM + // --------------------------------------------------------------------- + // • Persistência correta do ecossistema Node + // • NÃO persiste node_modules (por design) + // ===================================================================== + // npm cache (respeita NPM_CONFIG_CACHE) + "source=devcontainer-npm-cache,target=/home/node/.npm,type=volume", + // npm-global (bins globais, se utilizados) + "source=devcontainer-npm-global,target=/home/node/.npm-global,type=volume", + // ===================================================================== + // PROCESS & RUNTIME STATE + // --------------------------------------------------------------------- + // • Estado de processos gerenciados (PM2) + // • Logs, dumps e PIDs + // ===================================================================== + "source=devcontainer-pm2-state,target=/home/node/.pm2,type=volume", + // ===================================================================== + // USER CONFIGURATION (XDG — CONFIG / SHARE / STATE) + // --------------------------------------------------------------------- + // • Fonte de verdade para preferências do usuário + // • Inclui GitHub Copilot, Copilot Chat e extensões + // ===================================================================== + // XDG_CONFIG_HOME + "source=devcontainer-user-config,target=/home/node/.config,type=volume", + // XDG_DATA_HOME + "source=devcontainer-local-share,target=/home/node/.local/share,type=volume", + // XDG_STATE_HOME + // • Estado transitório de ferramentas, incluindo Copilot Chat + "source=devcontainer-local-state,target=/home/node/.local/state,type=volume", + // ===================================================================== + // AI / AGENT STATE + // --------------------------------------------------------------------- + // • Estado persistente de agentes locais (Claude, etc.) + // • NÃO inclui memória semântica remota (server-side) + // ===================================================================== + "source=devcontainer-claude-state,target=/home/node/.claude,type=volume", + // ===================================================================== + // IDENTITY & SECRETS (STRICT, NÃO COPIAR) + // --------------------------------------------------------------------- + // • Chaves NUNCA são persistidas como volume + // • Apenas forwarding seguro via agent + // ===================================================================== + // ========================================================= + // SSH AGENT FORWARDING — VS CODE NATIVE (v5.3+) + // --------------------------------------------------------- + // HISTÓRICO: + // v5.2: Mount manual "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent" + // v5.3: REMOVIDO - Causava erro fatal de type mismatch + // + // ERRO ANTERIOR: + // error mounting ... to rootfs at "/ssh-agent": not a directory: + // Are you trying to mount a directory onto a file (or vice-versa)? + // + // CAUSA: + // SSH_AUTH_SOCK é um socket UNIX (arquivo especial) + // Docker tentava montar como diretório + // Type mismatch → container não iniciava + // + // SOLUÇÃO: + // VS Code Remote Containers fornece SSH forwarding NATIVO + // Não requer mount manual + // Funciona automaticamente quando SSH agent está presente no host + // + // BENEFÍCIOS: + // ✅ Container inicia com ou sem SSH agent + // ✅ Zero configuração manual + // ✅ Fail-safe por design + // ✅ Cross-platform (Windows/Linux/macOS) + // + // VALIDAÇÃO: + // Dentro do container: + // $ echo $SSH_AUTH_SOCK # Deve mostrar path se agent disponível + // $ ssh-add -l # Lista chaves (se agent presente) + // $ ssh -T git@github.com # Testa autenticação GitHub + // ========================================================= + // GnuPG keyring (persistente, mas isolado) + "source=devcontainer-gpg-cache,target=/home/node/.gnupg,type=volume", + // ===================================================================== + // VS CODE SERVER (PERFORMANCE CRÍTICO) + // --------------------------------------------------------------------- + // • Binário do VS Code Server + // • Extensões (incluindo Copilot) + // • Estado de autenticação e UX + // + // ⚠️ Volume sensível — purge apenas se necessário. + // Geração TS7 v1: inicia o servidor remoto sem extensões herdadas do volume pré-migração. + // O volume `devcontainer-vscode-server` anterior permanece intacto para rollback/forense. + // ===================================================================== + "source=devcontainer-vscode-server-ts7-v1,target=/home/node/.vscode-server,type=volume", + // ===================================================================== + // UX — HISTÓRICO DE SHELL (FORA DO $HOME POR DESIGN) + // --------------------------------------------------------------------- + // • Histórico persistente entre rebuilds + // • Não polui o $HOME + // ===================================================================== + "source=devcontainer-bash-history,target=/home/node-history,type=volume", + // ===================================================================== + // INFRASTRUCTURE (DEV ONLY — ALTO PRIVILÉGIO) + // --------------------------------------------------------------------- + // • Docker CLI usa o socket do host + // • Equivale a root no host + // • NUNCA usar em produção + // ===================================================================== + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ], + "name": "ChatGPT Docker Puppeteer - Dev Container", + // Default oficial para portas não declaradas explicitamente. + // Use otherPortsAttributes em vez de wildcard "*" dentro de portsAttributes. + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, + "portsAttributes": { + // ---------------------------------------------------------- + // Política global: DENY-BY-DEFAULT + // ---------------------------------------------------------- + // • Qualquer porta NÃO declarada explicitamente é ignorada + // • Nenhuma inferência automática é permitida + // ---------------------------------------------------------- + // ================== UI HUMANA ============================== + "3008": { + "label": "Dashboard Principal — Mission Control (HTTP + Socket.io + API)", + "onAutoForward": "notify", + "protocol": "http", + "webRoot": "${containerWorkspaceFolder}/src/server" + }, + "5173": { + "label": "Vite Dev Server — Vue Dashboard (dev only)", + "onAutoForward": "notify", + "protocol": "http", + "webRoot": "${containerWorkspaceFolder}/src/dashboard-ui" + // NOTA: HMR (Hot Module Reload) configurado no vite.config.js + // - port: 5173 (HTTP + WebSocket na MESMA porta) + // - hmr.clientPort: 5173 (WebSocket fixado) + // - hmr.host: 'localhost' (Windows acesso via port forwarding) + // - watch.usePolling: true (Docker volumes file watching) + // ✅ WebSocket HMR usa a MESMA porta 5173 (não precisa forward adicional) + }, + // ================== INFRAESTRUTURA ========================= + "9224": { + "label": "Chrome Proxy Service (Container → Windows Host)", + "onAutoForward": "ignore", + "protocol": "http" + // NOTA ARQUITETURAL: + // • Esta porta ESTÁ em forwardPorts (sempre disponível) + // • onAutoForward: "ignore" = não notifica o usuário (porta interna) + // • Reconciliação conceitual: forward != auto-forward + // - forwardPorts: política de DISPONIBILIDADE (sempre expor) + // - onAutoForward: política de UX (notificar humano ou não) + // + // FUNÇÃO: + // • Proxy transparente HTTP + WebSocket (CDP) + // • Puppeteer → localhost:9224 → host.docker.internal:9225 + // • ÚNICA fronteira autorizada para Chrome externo + // • Porta 9225 (Chrome Real, Windows) NUNCA acessada diretamente + }, + // ================== DEBUG ================================== + "9229": { + "label": "Node.js Debug — Primary (agente-gpt)", + "onAutoForward": "silent", + "protocol": "http" + // NOTA: Node Inspector expõe endpoints HTTP (/json/list, /json/version) + // Não é HTTP REST típico, mas protocol: "http" evita interpretações erradas + // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9229 + }, + "9230": { + "label": "Node.js Debug — Fallback (dashboard-web)", + "onAutoForward": "silent", + "protocol": "http" + // ⚠️ Só funciona se processo rodar com: --inspect=0.0.0.0:9230 + } + }, + // ============================================================ + // LIFECYCLE HOOKS - Sincronia de Inicialização + // ============================================================ + // Chamamos bash explicitamente dentro do login shell. + // Motivo: + // bash -lc '