Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,13 @@ the `Renovate helper`. Load-bearing facts:
- **`.bazelversion` invalidates the lock too.** `MODULE.bazel.lock`'s `lockFileVersion` (and the
shape of its recorded extensions) tracks the bazel release, so a Renovate bazel bump leaves the
committed lock stale. Builds don't notice — `--lockfile_mode=update` rewrites in memory and stays
green — so the staleness surfaces either as a blocked local commit (the `bazel mod tidy`
pre-commit hook rewrites it on disk on any `go.mod` change) or as ci.yml's `MODULE.bazel.lock
freshness` job, which is the backstop for commits that ran no hook. The workflow therefore
triggers on `.bazelversion` and folds it into the same `bazel` classification as `MODULE.bazel`:
one output, one `bazel mod deps` refresh. bazelisk reads the checked-out `.bazelversion`, so the
regenerated lock is in the bumped version's format.
green — so the staleness surfaces either as a blocked local commit (`base-image-pin` selects
`.bazelversion` itself and rewrites the lock as a side effect of its `bazel build`;
`bazel mod tidy` does the same, but only on a `go.mod`/`go.work`/`go.sum` change) or as ci.yml's
`MODULE.bazel.lock freshness` job, which is the backstop for commits that ran no hook. The
workflow therefore triggers on `.bazelversion` and folds it into the same `bazel` classification
as `MODULE.bazel`: one output, one `bazel mod deps` refresh. bazelisk reads the checked-out
`.bazelversion`, so the regenerated lock is in the bumped version's format.
- **Go tidy/sync rides the same commit.** Renovate's `go get` bumps `go.mod`/`go.sum` but never
runs `go mod tidy` (opt-in) or `go work sync` (Renovate does it only when vendoring, which this
repo doesn't) — so the indirect block and `go.work.sum` are left stale. The workflow runs
Expand Down
2 changes: 2 additions & 0 deletions .devcontainer/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ py_test(
"post-create.sh",
"post-start.sh",
"//:.github/workflows/devcontainer.yml",
"//:.vscode/extensions.json",
"//:.vscode/settings.json",
],
main = "test_devcontainer_config.py",
deps = ["//meta/scripts:sync_base_image_pin_lib"],
Expand Down
13 changes: 12 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,20 @@
"ms-python.python",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters",
"timonwong.shellcheck",
],
"settings": {
"todo-tree.ripgrep.ripgrep": "/usr/bin/rg"
"todo-tree.ripgrep.ripgrep": "/usr/bin/rg",
"// Ruff format-on-save": "charliermarsh.ruff surfaces diagnostics inline; this block makes it the default Python formatter so `editor.formatOnSave` calls ruff format, and runs ruff check --fix + import-organize as save-time code actions. Config is read from `[tool.ruff]` in //:pyproject.toml — single source of truth shared with CI + pre-commit. Container-scoped because `editor.defaultFormatter` is the one setting that raises when its extension is absent, and ruff is not recommended for host windows.",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"// ShellCheck binary": "Container-scoped on purpose: this path exists only in here, and .vscode/settings.json would apply it to a host window too, where the extension fails to spawn rather than falling back. Pinning it means the editor and the CI job can only disagree by the version skew ci.yml documents; a host window uses the extension's bundled copy instead.",
"shellcheck.executablePath": "/usr/bin/shellcheck"
}
}
}
Expand Down
133 changes: 130 additions & 3 deletions .devcontainer/test_devcontainer_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the wiring between devcontainer.json, the Dockerfile, and the base image.
"""Tests for the wiring between devcontainer.json, the Dockerfile, the base image, and .vscode.

Three couplings live across those files and none of them fails loudly:
Four couplings live across those files and none of them fails loudly:

- The `BASE_IMAGE` override has an exact working shape. Every nearby shape either breaks
every local `devcontainer up` (an empty `--build-arg` overriding the Dockerfile default)
Expand All @@ -10,8 +10,11 @@
- The lifecycle hooks call the command the base image installs. Calling the workspace copy
instead still works here — the scripts are checked in — so the mistake would only surface
in Syndic/.dotfiles, which has no such copy.
- An extension this repo configures is installed by devcontainer.json. Settings for an
absent extension bind to nothing, so the feature is missing with no error anywhere.

The rationale for all three is in meta/devcontainer-base/README.md, "Consuming the image".
The rationale for the first three is in meta/devcontainer-base/README.md, "Consuming the
image".
The parsing helpers are pure so they can be exercised directly, same split as the shell tests
in this directory.
"""
Expand All @@ -28,6 +31,8 @@
# which a resolved symlink would lead back out of. The rest read fine either way.
_HERE = Path(__file__).parent
_DEVCONTAINER_JSON = _HERE / "devcontainer.json"
_SETTINGS_JSON = _HERE.parent / ".vscode" / "settings.json"
_EXTENSIONS_JSON = _HERE.parent / ".vscode" / "extensions.json"
_DOCKERFILE = _HERE / "Dockerfile"
_HOOKS = (_HERE / "post-create.sh", _HERE / "post-start.sh")
_DEVCONTAINER_WORKFLOW = _HERE.parent / ".github" / "workflows" / "devcontainer.yml"
Expand Down Expand Up @@ -171,6 +176,128 @@ def test_escaped_quote_does_not_end_the_string(self):
)


# Settings namespace -> the extension that contributes it. There is no offline registry for
# this — the link lives in each extension's own package.json — so it is a hand-kept table, and
# test_no_stale_mappings keeps it from rotting.
_CONFIGURED_BY = {
"bazel": "bazelbuild.vscode-bazel",
"coverage-gutters": "ryanluker.vscode-coverage-gutters",
"go": "golang.go",
"prettier": "esbenp.prettier-vscode",
"python": "ms-python.python",
"shellcheck": "timonwong.shellcheck",
"taskManager": "cnshenj.vscode-task-manager",
"todo-tree": "gruntfuggly.todo-tree",
"triggerTaskOnSave": "Gruntfuggly.triggertaskonsave",
"yaml": "redhat.vscode-yaml",
}

# VS Code's own namespaces, which no extension has to supply.
_BUILT_IN = frozenset({"editor", "files"})


def settings_namespaces(settings: dict) -> set[str]:
"""First segment of every real setting key. `// ...` are doc keys, `[lang]` are blocks."""
return {
key.split(".")[0]
for key in settings
if not key.startswith("//") and not key.startswith("[")
}


def named_formatters(settings: dict) -> set[str]:
"""Extension ids named outright as a formatter, at top level or in a [language] block."""
found = set()
for key, value in settings.items():
if key == "editor.defaultFormatter" and isinstance(value, str):
found.add(value)
elif key.startswith("[") and isinstance(value, dict):
found |= named_formatters(value)
return found


class TestConfiguredExtensions(unittest.TestCase):
"""Settings wire the editor to extensions; only devcontainer.json installs them.

`.vscode/extensions.json` is a recommendation list — a dismissable prompt — and is
deliberately not asserted against the install list in either direction. Recommending
something the container does not bundle is a legitimate thing to do, and the container
bundles one the list does not recommend (the task manager, which is container-only).

What has to hold is narrower: an extension this repo *configures* must be installed, or
the settings bind to nothing. That is how the shellcheck wiring first shipped — settings
and docs in place, extension absent, nothing raising a word about it.
"""

@classmethod
def setUpClass(cls):
config = json.loads(strip_jsonc(_DEVCONTAINER_JSON.read_text(encoding="utf-8")))
vscode = config["customizations"]["vscode"]
cls.installed = {name.casefold() for name in vscode["extensions"]}
cls.workspace = json.loads(strip_jsonc(_SETTINGS_JSON.read_text(encoding="utf-8")))
cls.container = vscode["settings"]
cls.settings = (cls.workspace, cls.container)
cls.namespaces = set().union(*(settings_namespaces(s) for s in cls.settings))
recommendations = json.loads(strip_jsonc(_EXTENSIONS_JSON.read_text(encoding="utf-8")))
cls.recommended = {name.casefold() for name in recommendations["recommendations"]}

def test_every_configured_extension_is_installed(self):
configured = {_CONFIGURED_BY[ns] for ns in self.namespaces if ns in _CONFIGURED_BY}
missing = sorted(e for e in configured if e.casefold() not in self.installed)
self.assertEqual(
missing,
[],
"configured in settings but not in devcontainer.json's extensions list",
)

def test_every_settings_namespace_is_accounted_for(self):
"""Without this, a new extension's settings simply miss the table and go unchecked."""
unknown = sorted(self.namespaces - set(_CONFIGURED_BY) - _BUILT_IN)
self.assertEqual(
unknown,
[],
"settings namespaces that are neither mapped in _CONFIGURED_BY nor listed in "
"_BUILT_IN — add each to whichever it is",
)

def test_no_stale_mappings(self):
stale = sorted(set(_CONFIGURED_BY) - self.namespaces)
self.assertEqual(stale, [], "_CONFIGURED_BY entries whose settings are gone")

def test_formatters_named_in_container_settings_are_installed(self):
"""`editor.defaultFormatter` names an extension by id rather than by namespace."""
missing = sorted(
n for n in named_formatters(self.container) if n.casefold() not in self.installed
)
self.assertEqual(missing, [], "named as editor.defaultFormatter but not installed")

def test_formatters_named_in_workspace_settings_reach_both_windows(self):
"""Scope decides which list has to carry the extension.

`editor.defaultFormatter` is the one setting that raises when its extension is absent —
"configured as formatter but it is not available", on every save — where an unknown
`go.*` or `coverage-gutters.*` key is simply inert. Workspace settings load in a host
window as well as in the container, so a formatter named here has to be reachable from
both lists.

Empty today by construction: the [python] block lives in devcontainer.json's
container-scoped settings precisely because ruff is not recommended host-side. That is
the point — this fires the moment a formatter is named at a scope the host also reads.
"""
named = named_formatters(self.workspace)
self.assertEqual(
sorted(n for n in named if n.casefold() not in self.recommended),
[],
"named as a formatter in .vscode/settings.json, which a host window reads, but not "
"recommended there — move the block to devcontainer.json or recommend the extension",
)
self.assertEqual(
sorted(n for n in named if n.casefold() not in self.installed),
[],
"named as a formatter in .vscode/settings.json but not installed in the container",
)


class TestLocalEnvParsing(unittest.TestCase):
def test_default_is_returned(self):
self.assertEqual(parse_local_env("${localEnv:X:fallback}"), ("X", "fallback"))
Expand Down
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,16 @@ jobs:
args: format --check
- run: ruff check

# shellcheck: mirrors the pre-commit hook of the same name. The runner image ships
# shellcheck, so there's nothing to install. Scoped to *.sh, which today means the host stub
# in .devcontainer/ and the shared library in meta/devcontainer-base/ — privileged writes
# shellcheck: the enforcing half. The editor surfaces the same findings inline on save
# (timonwong.shellcheck, configured in .vscode/settings.json), but nothing blocks a commit on
# them, so this job is where shell lint is actually gated. The runner image ships shellcheck,
# so there's nothing to install. Scoped to *.sh, which today means .devcontainer/'s
# host stub and lifecycle hooks plus meta/devcontainer-base/scripts/ — privileged writes
# against /etc and host-absolute paths, where `set -e` foot-guns (errexit suspension inside
# `||` callees, most recently) are worth catching mechanically rather than in review.
# The runner's shellcheck and the devcontainer's Debian package are separately versioned
# with no shared pin, so the two can disagree; CI is authoritative if they ever do.
# The runner's shellcheck and the devcontainer's Debian package (which the editor extension
# is pointed at) are separately versioned with no shared pin, so the two can disagree; CI is
# authoritative if they ever do.
shellcheck:
name: shellcheck
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/renovate-derived-files.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ on:
# A bazel version bump re-derives the lock too: `lockFileVersion` and the recorded
# extension shape track the bazel release, so a bumped `.bazelversion` leaves the
# committed lock stale. CI hides that (`--lockfile_mode=update` rewrites in memory),
# but the `bazel mod tidy` pre-commit hook rewrites it on disk and blocks the commit.
# but the `base-image-pin` pre-commit hook — the one whose `files` selects `.bazelversion`
# — rewrites it on disk as a side effect of its `bazel build` and blocks the commit.
- ".bazelversion"
- "pyproject.toml"
- "uv.lock"
Expand Down
9 changes: 0 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,6 @@ repos:
pass_filenames: false
files: \.go$

# The devcontainer plumbing runs privileged writes against /etc and host-absolute
# paths, and `set -e` interacts badly enough with && chains that the foot-guns are
# worth catching mechanically. Mirrored by the shellcheck job in ci.yml.
- id: shellcheck
name: shellcheck
language: system
entry: shellcheck
files: \.sh$

- id: check-secrets-dir
name: check secrets directory
language: python
Expand Down
6 changes: 6 additions & 0 deletions .shellcheckrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Follow `# shellcheck source=` directives into files outside the invocation's argument list.
# Without this, whether SC1091 fires depends on how the caller batches its arguments: ci.yml
# passes every tracked *.sh at once, so lib.sh counts as an input and the source is followed,
# while the editor lints the open buffer alone and would flag devcontainer-plumbing.sh on every
# save. An rc file rather than `-x` per caller so both read one config.
external-sources=true
21 changes: 11 additions & 10 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
{
// Host-window recommendations, not a mirror of what the devcontainer installs. That list is
// devcontainer.json's `customizations.vscode.extensions`, which auto-installs inside the
// container; this one is a dismissable prompt and is the only list that does anything in a
// window that has not reopened in the container.
//
// It carries what is useful for editing this repo on the host: markdown, YAML, workflows,
// CSVs. Most of the rest cannot work there at all — go, ty, bazel, the coverage report and
// the on-save task scripts all drive tooling that lives in the container. Ruff and ShellCheck
// are the two that could, since both ship a bundled binary, and are left out deliberately
// rather than for that reason: Python and shell are edited in the container, where the pinned
Comment thread
Syndic marked this conversation as resolved.
// versions are the ones CI runs. Their settings are container-scoped to match.
"recommendations": [
Comment thread
Syndic marked this conversation as resolved.
"astral-sh.ty",
"bazelbuild.vscode-bazel",
"charliermarsh.ruff",
"esbenp.prettier-vscode",
"github.vscode-github-actions",
"golang.go",
"gruntfuggly.todo-tree",
"gruntfuggly.triggertaskonsave",
"mechatroner.rainbow-csv",
"ms-azuretools.vscode-containers",
"ms-kubernetes-tools.vscode-kubernetes-tools",
"ms-python.debugpy",
"ms-python.python",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters",
]
}
13 changes: 4 additions & 9 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,9 @@
"prettier.proseWrap": "always",
"prettier.trailingComma": "all",
"python.languageServer": "Default",
"// Ruff format-on-save": "charliermarsh.ruff surfaces diagnostics inline; this block makes it the default Python formatter so `editor.formatOnSave` calls ruff format, and runs ruff check --fix + import-organize as save-time code actions. Config is read from `[tool.ruff]` in //:pyproject.toml — single source of truth shared with CI + pre-commit.",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"// ShellCheck": "timonwong.shellcheck surfaces shellcheck diagnostics inline, matching what the CI `shellcheck` job enforces. The extension is container-side only, so executablePath is set in devcontainer.json's customizations.vscode.settings; these two keys are harmless where it is absent, unlike a formatter reference. useWorkspaceRootAsCwd is load-bearing: `# shellcheck source=` directives are repo-root-relative and .shellcheckrc lives at the root, so linting from the file's own directory would break both.",
"shellcheck.run": "onSave",
"shellcheck.useWorkspaceRootAsCwd": true,
"// Task Manager favorites": "Pins the Bazel build/test tasks to the cnshenj.vscode-task-manager sidebar (recommended in the devcontainer; see devcontainer.json). Names must match the `label` fields in tasks.json. The `UnnaturalDesigns/Workspace/` prefix is Task Manager's own scoping convention.",
"taskManager.favorites": [
"UnnaturalDesigns/Workspace/Bazel Build all",
Expand All @@ -44,7 +39,7 @@
"[]",
"[x]"
],
"// Repo-health checks on save": "These scripts run via the VS Code tasks defined in tasks.json, triggered by gruntfuggly.triggertaskonsave (recommended in extensions.json) when the matching files are saved. The matchers in tasks.json turn each `path:line: message` failure into a Problems-panel entry with a squiggle at the offending line.",
"// Repo-health checks on save": "These scripts run via the VS Code tasks defined in tasks.json, triggered by gruntfuggly.triggertaskonsave (installed by devcontainer.json; it drives the container's tooling, so it is not among the host-window recommendations) when the matching files are saved. The matchers in tasks.json turn each `path:line: message` failure into a Problems-panel entry with a squiggle at the offending line.",
"triggerTaskOnSave.tasks": {
"check: modules": [
"**/go.mod",
Expand Down
14 changes: 14 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ exports_files([
".github/workflows/devcontainer.yml",
".github/workflows/renovate-derived-files.yml",
])

# Same posture for the pre-commit config and the README that documents it: the hook table is a
# hand-copy of the config's ids, so //meta/scripts:test_precommit_docs reads both real files.
exports_files([
".pre-commit-config.yaml",
"README.md",
])

# Editor settings name extensions, and which list has to carry one depends on the settings
# scope — see //.devcontainer:test_devcontainer_config.
exports_files([
".vscode/extensions.json",
".vscode/settings.json",
])
Loading