Skip to content

Commit 8c34ef5

Browse files
aledbfclaude
andcommitted
test(config): gate devcontainer.json spec compliance in CI; add secrets
Add TestSpecCompliance: it loads the vendored official devContainer.base.schema .json and asserts, by reflection over the config struct tree, that every property the spec defines is modeled by a json tag — so nothing the spec defines is silently dropped on parse/read-configuration/metadata round-trip. It is the config analog of TestFlagInventoryParity and excludes our Go-only additions by construction (it only fails on spec properties MISSING from our structs, never on extra fields we carry). A tiny, justified nonModeled allowlist covers schema-meta keys and sub-properties of open maps (features/secrets/gpu). Writing the gate immediately surfaced a real gap: the spec's `secrets` (recommended-secrets metadata) was not modeled and was being dropped — add it as an open map so it round-trips losslessly. Proven: dropping a modeled field makes the gate fail. Wire it as a visible CI step (`task spec:compliance`) on the PR and release lanes, and add `task spec:schema-update` to refresh the pinned schema from devcontainers/spec — a new spec property then fails the gate until it is modeled, which is the "always following the spec" guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 64aeee3 commit 8c34ef5

6 files changed

Lines changed: 921 additions & 0 deletions

File tree

.github/workflows/go-cli.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
version: 3.x
2424

2525
- run: task lint
26+
- run: task spec:compliance
2627
- run: task vuln
2728
- run: task test:race
2829
- run: task coverage

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
version: 3.x
2929

3030
- run: task lint
31+
- run: task spec:compliance
3132
- run: task vuln
3233
- run: task test:race
3334
- run: task coverage

Taskfile.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ tasks:
8888
cmds:
8989
- go vet ./cmd/... ./internal/...
9090

91+
spec:compliance:
92+
desc: Verify config structs model every devcontainer.json schema property (spec base, excludes Go-only additions)
93+
cmds:
94+
- go test ./internal/config -run TestSpecCompliance -count=1
95+
96+
spec:schema-update:
97+
desc: Refresh the vendored devcontainer.json schema the spec-compliance gate checks against
98+
cmds:
99+
- curl -fsSL https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json -o internal/config/testdata/devContainer.base.schema.json
100+
- 'echo "Schema refreshed. Run ''task spec:compliance'' — any new spec property fails until modeled in internal/config/types.go."'
101+
91102
reference:
92103
desc: Install and compile the TypeScript parity oracle
93104
cmds:
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"reflect"
7+
"sort"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestSpecCompliance gates devcontainer.json schema compliance: every property
13+
// defined in the official devContainer.base.schema.json MUST be modeled by a
14+
// json tag somewhere in the config struct tree, so nothing the spec defines is
15+
// silently dropped on parse / read-configuration / metadata round-trip.
16+
//
17+
// This is the config analog of TestFlagInventoryParity. It excludes our Go-only
18+
// additions by construction: it only fails when a SPEC property is missing from
19+
// our structs, never when we carry extra fields the spec doesn't define.
20+
//
21+
// The schema is vendored (pinned) at testdata/devContainer.base.schema.json from
22+
// github.com/devcontainers/spec (schemas/devContainer.base.schema.json). Refresh
23+
// it with `task spec:schema-update`; a new spec property then fails this test
24+
// until it is added to the struct — that is the "always following the spec" gate.
25+
func TestSpecCompliance(t *testing.T) {
26+
// nonModeled lists schema property names we deliberately do not model as
27+
// dedicated struct fields, each with the reason. Everything else MUST map to
28+
// a json tag. Keep this list tiny and justified — a genuinely new config
29+
// property must NOT be added here to silence the test; add the struct field.
30+
nonModeled := map[string]string{
31+
"$schema": "JSON-schema meta key, not a devcontainer.json property",
32+
"additionalProperties": "JSON-schema keyword surfaced by the walk, not a property",
33+
// Deprecated legacy feature keys nested under `features` (an open map we
34+
// preserve wholesale via Features map[string]interface{}).
35+
"fish": "legacy feature key under `features`",
36+
"maven": "legacy feature key under `features`",
37+
"gradle": "legacy feature key under `features`",
38+
"jupyterlab": "legacy feature key under `features`",
39+
"homebrew": "legacy feature key under `features`",
40+
// Sub-properties of a `secrets` value (open map, preserved wholesale).
41+
"description": "sub-property of a `secrets` value",
42+
"documentationUrl": "sub-property of a `secrets` value",
43+
// Sub-property of hostRequirements.gpu, which we preserve as raw JSON.
44+
"cores": "sub-property of hostRequirements.gpu (kept as raw JSON)",
45+
}
46+
47+
schemaProps := schemaPropertyNames(t)
48+
structTags := structJSONTags(reflect.TypeOf(DevContainer{}))
49+
// MountOrString wraps its value in an interface, so reflection can't reach
50+
// the Mount struct through it — seed it explicitly.
51+
for name := range structJSONTags(reflect.TypeOf(Mount{})) {
52+
structTags[name] = true
53+
}
54+
55+
var missing []string
56+
for name := range schemaProps {
57+
if _, skip := nonModeled[name]; skip {
58+
continue
59+
}
60+
if !structTags[name] {
61+
missing = append(missing, name)
62+
}
63+
}
64+
sort.Strings(missing)
65+
if len(missing) > 0 {
66+
t.Fatalf("devcontainer.json schema properties not modeled by config structs (add the field, or justify in nonModeled): %v", missing)
67+
}
68+
}
69+
70+
// schemaPropertyNames returns every property name defined anywhere under a
71+
// "properties" object in the vendored schema.
72+
func schemaPropertyNames(t *testing.T) map[string]bool {
73+
t.Helper()
74+
data, err := os.ReadFile("testdata/devContainer.base.schema.json")
75+
if err != nil {
76+
t.Fatalf("read vendored schema: %v (run `task spec:schema-update`)", err)
77+
}
78+
var root interface{}
79+
if err := json.Unmarshal(data, &root); err != nil {
80+
t.Fatalf("parse schema: %v", err)
81+
}
82+
out := map[string]bool{}
83+
var walk func(node interface{})
84+
walk = func(node interface{}) {
85+
switch n := node.(type) {
86+
case map[string]interface{}:
87+
if props, ok := n["properties"].(map[string]interface{}); ok {
88+
for k := range props {
89+
out[k] = true
90+
}
91+
}
92+
for _, v := range n {
93+
walk(v)
94+
}
95+
case []interface{}:
96+
for _, v := range n {
97+
walk(v)
98+
}
99+
}
100+
}
101+
walk(root)
102+
return out
103+
}
104+
105+
// structJSONTags collects every json tag name reachable from t (recursing into
106+
// struct fields, pointers, slices/arrays and map values).
107+
func structJSONTags(t reflect.Type) map[string]bool {
108+
out := map[string]bool{}
109+
seen := map[reflect.Type]bool{}
110+
var visit func(t reflect.Type)
111+
visit = func(t reflect.Type) {
112+
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
113+
t = t.Elem()
114+
}
115+
if t.Kind() == reflect.Map {
116+
visit(t.Elem())
117+
return
118+
}
119+
if t.Kind() != reflect.Struct || seen[t] {
120+
return
121+
}
122+
seen[t] = true
123+
for i := 0; i < t.NumField(); i++ {
124+
f := t.Field(i)
125+
if name := strings.Split(f.Tag.Get("json"), ",")[0]; name != "" && name != "-" {
126+
out[name] = true
127+
}
128+
visit(f.Type)
129+
}
130+
}
131+
visit(t)
132+
return out
133+
}

0 commit comments

Comments
 (0)