|
| 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