diff --git a/docs/config.md b/docs/config.md index 06623482..2fd671c5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -80,10 +80,12 @@ umask, setgid inheritance, and default ACLs; see [Operations](operations.md#cach Every nonempty relative configured destination or reference path resolves from the canonical directory of the file that supplies its surviving declaration. -This includes imported fragments and selected configuration overlays. Built-in -relative destinations use the selected main configuration file's directory; -explicit `--output-dir` and `--debug-dir` values use the invocation working -directory. Absolute values remain unchanged. +This includes imported fragments, selected configuration overlays, and values +copied through inheritance: the copied value keeps its original declaring +file. A child-supplied replacement resolves from the child's declaring file. +Built-in relative destinations use the selected main configuration file's +directory; explicit `--output-dir` and `--debug-dir` values use the invocation +working directory. Absolute values remain unchanged. The root file can use ConfigKit imports to compose disjoint definitions. Imports and selected overlays remain under the configuration root; they do not merge or diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 714965cf..0f3b8c2c 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -28,10 +28,13 @@ at their respective [CLI](cli.md), [pipeline](pipeline.md), and ## Loading And Validation -ConfigKit acquires the selected root, imports, optional selected overlay, and -explicit CLI overrides in precedence order. Its document definition supplies -typed defaults, source-aware path normalization, and structural validation -before conversion to the independently owned runtime **Config**. Only +ConfigKit composes the selected root, imports, optional selected overlay, and +explicit CLI overrides in precedence order, then expands native sibling +inheritance before typed decoding. The document definition supplies typed +defaults and structural validation; Notarius's recorded resolution changes +normalize configured paths after decode. Path normalization follows the source +of the surviving value, including a value copied through inheritance, rather +than the inheritance directive's location. Only `NOTARIUS_CONFIG` participates in discovery; retired field-level environment variables do not participate in loading. Version 5 decoding rejects scalar module bindings and scalar external references. ConfigKit retains redacted, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a8942a9e..62761b4a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -227,6 +227,8 @@ not change defaults or add CLI flags for arbitrary configuration overrides. ## Stage 4 — Verify Declaration-Relative Paths Through Inheritance +> Completion: complete. + Goal: keep inherited resource paths anchored to the original declaration. Inspect `document_paths.go`, `document_paths_test.go`, the original-path map in diff --git a/internal/core/config/inheritance_test.go b/internal/core/config/inheritance_test.go index be472cb4..efa91f67 100644 --- a/internal/core/config/inheritance_test.go +++ b/internal/core/config/inheritance_test.go @@ -331,3 +331,105 @@ pipelines: t.Fatalf("override-inherited repair budget = %d, want 3", got) } } + +func TestInheritedReferencePathsKeepDeclaringSource(t *testing.T) { + t.Parallel() + + root := t.TempDir() + parentDirectory := filepath.Join(root, "parents") + childDirectory := filepath.Join(root, "children") + overlayDirectory := filepath.Join(root, "overlays") + fixtureDirectory := filepath.Join(root, "fixtures") + for _, directory := range []string{parentDirectory, childDirectory, overlayDirectory, fixtureDirectory} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"shared.txt", "parent.txt", "child.txt", "step.txt", "overlay.txt"} { + if err := os.WriteFile(filepath.Join(fixtureDirectory, name), []byte("fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + parent := filepath.Join(parentDirectory, "pipelines.yml") + if err := os.WriteFile(parent, []byte(`pipelines: + " parent ": + input: {module: input} + references: + " shared ": {path: ../fixtures/shared.txt} + nested: + extends: " shared " + source: {path: ../fixtures/parent.txt} + steps: + - id: first + references: + step-source: {path: ../fixtures/step.txt} + artifacts: + summary: + extract: {module: extract} +`), 0o600); err != nil { + t.Fatal(err) + } + child := filepath.Join(childDirectory, "pipelines.yml") + if err := os.WriteFile(child, []byte(`pipelines: + child: + extends: " parent " + references: + source: {path: ../fixtures/child.txt} +`), 0o600); err != nil { + t.Fatal(err) + } + main := filepath.Join(root, "notarius.yml") + if err := os.WriteFile(main, []byte(`version: 5 +configkit: + imports: + - path: parents/pipelines.yml + - path: children/pipelines.yml +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(overlayDirectory, "development.yml"), []byte(`pipelines: + " parent ": + references: + " shared ": {path: ../fixtures/overlay.txt} +`), 0o600); err != nil { + t.Fatal(err) + } + + loaded, err := loadConfig(main, LoadOptions{}) + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + assertReferencePath := func(pipelineID, referenceID, want string) { + t.Helper() + if got := loaded.Config.Pipelines[pipelineID].References[referenceID].Path; got != want { + t.Errorf("%s reference %s path = %q, want %q", pipelineID, referenceID, got, want) + } + } + assertReferencePath("parent", "shared", filepath.Join(fixtureDirectory, "shared.txt")) + assertReferencePath("child", "shared", filepath.Join(fixtureDirectory, "shared.txt")) + assertReferencePath("child", "nested", filepath.Join(fixtureDirectory, "shared.txt")) + assertReferencePath("parent", "source", filepath.Join(fixtureDirectory, "parent.txt")) + assertReferencePath("child", "source", filepath.Join(fixtureDirectory, "child.txt")) + if got := loaded.Config.Pipelines["child"].Steps[0].References["step-source"].Path; got != filepath.Join(fixtureDirectory, "step.txt") { + t.Errorf("inherited step reference path = %q", got) + } + + overlaid, err := loadConfig(main, LoadOptions{Overlay: "development"}) + if err != nil { + t.Fatalf("loadConfig() with overlay error = %v", err) + } + for _, pipelineID := range []string{"parent", "child"} { + for _, referenceID := range []string{"shared", "nested"} { + if got, want := overlaid.Config.Pipelines[pipelineID].References[referenceID].Path, filepath.Join(fixtureDirectory, "overlay.txt"); got != want { + t.Errorf("overlaid %s reference %s path = %q, want %q", pipelineID, referenceID, got, want) + } + } + } + if got, want := overlaid.Config.Pipelines["child"].References["source"].Path, filepath.Join(fixtureDirectory, "child.txt"); got != want { + t.Errorf("child replacement path = %q, want %q", got, want) + } + if got, want := overlaid.Config.Pipelines["child"].Steps[0].References["step-source"].Path, filepath.Join(fixtureDirectory, "step.txt"); got != want { + t.Errorf("overlaid inherited step reference path = %q, want %q", got, want) + } +}