From 0b0df40b5c129ac6c7fc1b8cce95081a69b2a0f3 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 11 Jun 2026 14:19:42 +0200 Subject: [PATCH] feat(config): add assignment inheritance via 'extends' with abstract bases --- config/assignment.go | 10 ++++++++++ config/inheritance.go | 15 +++++++++++++++ config/inheritance_test.go | 36 ++++++++++++++++++++++++++++++++++++ docs/configuration.md | 31 +++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/config/assignment.go b/config/assignment.go index 4b6f94b..1369ac1 100644 --- a/config/assignment.go +++ b/config/assignment.go @@ -27,6 +27,16 @@ func GetAssignmentConfig(course, assignment string, onlyForStudentsOrGroups ...s Msg("configuration for assignment not found") } + // Abstract assignments are bases for `extends` only and must not be used + // directly. Checked before resolving inheritance so the (own) flag is read, + // not an inherited one. + if assignmentIsAbstract(course, assignment) { + log.Fatal(). + Str("course", course). + Str("assignment", assignment). + Msg("assignment is abstract (a base for 'extends') and cannot be used directly") + } + // Resolve `extends` inheritance before reading any fields so the rest of // the config loading sees the merged, effective configuration. resolveAssignmentInheritance(course, assignment) diff --git a/config/inheritance.go b/config/inheritance.go index 8f92100..5f6fe1a 100644 --- a/config/inheritance.go +++ b/config/inheritance.go @@ -16,6 +16,19 @@ import ( // assignmentpath: blatt-10 # ... and override only what differs const inheritKey = "extends" +// abstractKey marks an assignment as an abstract base that exists only to be +// inherited from via `extends`. Abstract assignments cannot be operated on +// directly (generate, protect, clone, …). The flag itself is never inherited. +const abstractKey = "abstract" + +// assignmentIsAbstract reports whether the assignment declares `abstract: true` +// on itself. It reads the assignment's own value and must be called before +// resolveAssignmentInheritance writes the merged config back into viper, so an +// inherited abstract flag never makes a concrete child abstract. +func assignmentIsAbstract(course, assignment string) bool { + return viper.GetBool(course + "." + assignment + "." + abstractKey) +} + // resolveAssignmentInheritance resolves the `extends` chain for the given // assignment and writes the merged, effective configuration back into viper at // the assignment key. After this call the rest of the config loading reads the @@ -35,7 +48,9 @@ func resolveAssignmentInheritance(course, assignment string) { } merged := mergedAssignmentMap(course, assignment, map[string]bool{}) + // Meta keys must never leak into the effective config or be inherited. delete(merged, inheritKey) + delete(merged, abstractKey) viper.Set(assignmentKey, merged) } diff --git a/config/inheritance_test.go b/config/inheritance_test.go index 730a9dd..c59fc1d 100644 --- a/config/inheritance_test.go +++ b/config/inheritance_test.go @@ -180,6 +180,42 @@ func TestInheritance_NoExtendsIsUnaffected(t *testing.T) { } } +func TestAssignmentIsAbstract(t *testing.T) { + baseAssignment(t) + + // blatt09 (from baseAssignment) is concrete. + if assignmentIsAbstract("mpd", "blatt09") { + t.Fatal("blatt09 should not be abstract") + } + + // A declared abstract base. + viper.Set("mpd.defaults", true) + viper.Set("mpd.defaults.abstract", true) + viper.Set("mpd.defaults.per", "student") + if !assignmentIsAbstract("mpd", "defaults") { + t.Fatal("defaults should be abstract") + } + + // A child extending an abstract base must NOT itself become abstract: + // the flag is read from the own value before inheritance is resolved. + viper.Set("mpd.blatt10", true) + viper.Set("mpd.blatt10.extends", "defaults") + viper.Set("mpd.blatt10.assignmentpath", "blatt-10") + if assignmentIsAbstract("mpd", "blatt10") { + t.Fatal("blatt10 extends an abstract base but must not be abstract itself") + } + + // And after full resolution the abstract flag must not leak into the + // effective config. + cfg := GetAssignmentConfig("mpd", "blatt10") + if cfg.Per != PerStudent { + t.Fatalf("Per = %q, want inherited student", cfg.Per) + } + if viper.GetBool("mpd.blatt10.abstract") { + t.Fatal("abstract flag leaked into resolved blatt10 config") + } +} + func TestDeepMerge(t *testing.T) { parent := map[string]any{ "a": 1, diff --git a/docs/configuration.md b/docs/configuration.md index 88c704f..3ad6907 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -211,6 +211,37 @@ Notes: - Inheritance chains are allowed (`blatt11` → `blatt10` → `blatt09`); values are resolved transitively. Cycles and missing parents are reported as errors. +#### Abstract base assignments + +A common pattern is a base entry that holds shared configuration and is only ever +inherited from, never generated. Mark it with `abstract: true` — much like an +abstract class. Any command targeting it directly (`generate`, `protect`, +`clone`, …) is rejected with an error. + +```yaml +defaults: + abstract: true # base only — `glabs generate mpd defaults` is refused + per: student + mergeRequest: + mergeMethod: semi_linear + squashOption: never + pipeline: true + branches: + - name: main + mergeOnly: true + +blatt01: + extends: defaults # inherits everything above + assignmentpath: blatt-01 + description: Blatt 1 + startercode: + url: git@gitlab.lrz.de:mpd/startercode/blatt-01.git + fromBranch: template +``` + +The `abstract` flag is **not inherited**: `blatt01` above stays a normal, +generatable assignment. + ### Access level values | Level | Value | Permissions |