Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .devcontainer/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ py_test(
"post-create.sh",
"post-start.sh",
"//:.github/workflows/devcontainer.yml",
"//:.vscode/settings.json",
],
main = "test_devcontainer_config.py",
deps = ["//meta/scripts:sync_base_image_pin_lib"],
Expand Down
5 changes: 4 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@
"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",
"// 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
104 changes: 101 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,7 @@
# 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"
_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 +175,100 @@ 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.settings = (
json.loads(strip_jsonc(_SETTINGS_JSON.read_text(encoding="utf-8"))),
vscode["settings"],
)
cls.namespaces = set().union(*(settings_namespaces(s) for s in cls.settings))

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_extensions_named_as_formatters_are_installed(self):
"""`editor.defaultFormatter` names an extension by id rather than by namespace."""
named = set().union(*(named_formatters(s) for s in self.settings))
missing = sorted(n for n in named if n.casefold() not in self.installed)
self.assertEqual(missing, [], "named as editor.defaultFormatter but not installed")


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
20 changes: 10 additions & 10 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
{
// 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.
//
// So it carries only extensions that work with nothing but the host: the repo's tooling —
// go, ruff, ty, bazel, shellcheck, the coverage report, the on-save task scripts — lives in
// the container, and an extension recommended without it sits there broken. Editing markdown
// or YAML on the host is the case this serves, and .vscode/settings.json's prettier and
// todo-tree settings apply there.
"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",
]
}
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"source.organizeImports.ruff": "explicit"
}
},
"// ShellCheck": "timonwong.shellcheck surfaces shellcheck diagnostics inline, matching what the CI `shellcheck` job enforces. Only the host-safe half lives here; the container-only executablePath is in devcontainer.json's customizations.vscode.settings. 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.",
Comment thread
Syndic marked this conversation as resolved.
Outdated
"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 +47,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
11 changes: 11 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ 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 that only devcontainer.json installs — see
# //.devcontainer:test_devcontainer_config.
exports_files([".vscode/settings.json"])
Loading