diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3b053d2..10a1381 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Configuration Overlay Rename Implementation Plan -Status: Stage 1 complete. Execute Stage 2. +Status: complete. ## Scope and operating instructions @@ -188,3 +188,23 @@ Report the stage commit, validation results, any skipped platform-specific cases and the API/filesystem migration summary. The implementation is complete only when both stages pass and the roadmap's target surface and preserved behavior are accounted for. There are no unresolved product questions in this plan. + +### Completion + +Completed 2026-09-23. Added regression coverage that accepts only +`overlays/.yml` for selection, rejects a legacy-only selected file with +`CodeOverlayMissing`, and ignores a legacy sibling when the new selected file is +present. Explicit imports from a directory named `profiles` remain base-layer +imports. The maintained consumer confirms `ModelProfile` is unchanged while its +selected source reports `overlays/testing.yml` and `LayerOverlay`; public +layer/code strings and rendered explanation values are covered. + +Validation passed with `./scripts/check.sh` and `GOPROXY=off go test -race +./internal/engine` on Go 1.26.8/linux. No additional platform-specific validation +was required; existing symlink coverage remains conditional on Linux or macOS. +The API audit found no legacy Go declarations, and the remaining `profile` +references are intentionally limited to application-domain fields, upstream or +historical records, migration guidance, and explicit legacy-path regressions. The +feature roadmap is now a completion summary; v0.4.0 release preparation must still +describe the Go API, selected-file directory, provenance layer, and diagnostic-code +breaks. diff --git a/docs/roadmap/overlays.md b/docs/roadmap/overlays.md index dbfb7e5..dbbfd5b 100644 --- a/docs/roadmap/overlays.md +++ b/docs/roadmap/overlays.md @@ -1,139 +1,20 @@ -# Rename Configuration Profiles to Overlays +# Configuration Overlay Rename -Status: approved target, not yet implemented. Execution steps and stage status -belong to the [implementation plan](implementation.md). +Status: implementation complete; release preparation remains separate. -## Purpose and release scope +ConfigKit now calls the selected configuration feature an overlay. The current +behavior is owned by the [selected-overlay contract](../config.md#selected-overlays), +and upgrade instructions are in the [consumer migration guide](../consumers/README.md#selected-overlay-migration). -ConfigKit's name "profile" collides with application-owned model, pipeline, and -execution profiles. The feature already applies a named configuration overlay; -use that name directly instead of requiring consumers to distinguish another -kind of profile. +The completed migration intentionally changed four breaking surfaces: -The maintainer has approved a breaking rename for the next minor release, -intended as v0.4.0. There will be no backward-compatibility aliases, deprecated -fields, dual diagnostic names, or legacy-directory fallback. This roadmap does -not authorize publication or changes to downstream repositories. +- Go API identifiers, including `Options.Overlay`, `LayerOverlay`, and + `CodeOverlay*`; +- selected-file location to `overlays/.yml` without a `profiles/` fallback; +- provenance layer value to `overlay`; and +- diagnostic-code strings to `overlay.*`. -The baseline is ConfigKit at `aead20f`, following v0.3.1 and the release-workflow -simplification. Current behavior is owned by the -[selected-overlay contract](../config.md#selected-overlays). This roadmap owns -the unimplemented rename; it is not a new current API reference. - -## Target terminology and public surface - -Use **configuration overlay** in introductory prose and **selected overlay** or -**overlay** where context is clear. An overlay is the named configuration input; -overlaying is also the merge operation. CLI overrides may use that operation -without becoming named configuration overlays or changing their CLI provenance. - -| Current surface | Target surface | -| --- | --- | -| `Options[T].Profile string` | `Options[T].Overlay string` | -| `/profiles/.yml` | `/overlays/.yml` | -| Source label `profiles/.yml` | Source label `overlays/.yml` | -| `LayerProfile`, value `"profile"` | `LayerOverlay`, value `"overlay"` | -| `CodeProfileInvalid`, `profile.invalid` | `CodeOverlayInvalid`, `overlay.invalid` | -| `CodeProfileMissing`, `profile.missing` | `CodeOverlayMissing`, `overlay.missing` | -| `CodeProfileOutsideRoot`, `profile.outside_root` | `CodeOverlayOutsideRoot`, `overlay.outside_root` | -| `CodeProfileRepeated`, `profile.repeated` | `CodeOverlayRepeated`, `overlay.repeated` | -| `CodeProfileDirective`, `profile.directive` | `CodeOverlayDirective`, `overlay.directive` | -| `CodeProfileVersion`, `profile.version` | `CodeOverlayVersion`, `overlay.version` | -| `CodeProfileLimitExceeded`, `profile.limit_exceeded` | `CodeOverlayLimitExceeded`, `overlay.limit_exceeded` | -| Maintained example `LoadNarratioProfile` | `LoadNarratioOverlay` | - -Apply these changes to public declarations, engine counterparts, facade -forwarding/conversion, diagnostics, source records, origin/history records, and -rendered explanations. Messages describing this ConfigKit feature use "overlay". -Serialization and output that carry the layer or diagnostic-code strings use -the new values. No legacy values are emitted for compatibility. - -Not every error during loading currently has a `profile.*` code: shared options, -source, import, and parser classifications remain unchanged. This is not an -opportunity to redesign error categories or add new diagnostic guarantees. - -## Behavior preserved - -This is a naming migration, not a new layering model. Preserve the existing -contract except for the explicitly renamed surfaces: - -- Select at most one overlay by name, not a path or list. Preserve the current - ASCII name grammar, case sensitivity, and lack of extension fallback. -- Empty selection and exact lowercase `default` continue to mean no selection. - `overlays/default.yml` is not implicitly loaded. The sentinel is existing - behavior, not a compatibility alias for the old feature name. -- Load the selected mapping after strict base composition and schema-version - checks, before CLI overrides and defaults/typed decoding. Preserve existing - precedence, replacement rules, explicit null/empty behavior, and error ordering. -- Preserve filesystem-context requirements, canonical containment and duplicate - source checks, aggregate limits, directive/version-path rules, and `NoSource` - option validation. Do not strengthen or relax them as part of this rename. -- Preserve source-ID ordering, detached metadata, history, provenance queries, - callback lifetime, secret handling, and load independence. Only the named - layer, selected-file labels/paths, and corresponding diagnostic terminology - change where specified above. -- Keep CLI overrides and strict composition distinct. Do not add overlay stacks, - inheritance, discovery, arbitrary file selection, or new merge options. - -## No backward compatibility - -Old Go identifiers are removed rather than retained as aliases. Applications -using them must update before they compile against the new minor version. - -Selection searches only `overlays/.yml`. A file present solely in -`profiles/` does not satisfy selection and produces the corresponding new -overlay diagnostic. No automatic rename, migration, or search of both locations -is performed. If both directories exist, only the selected path under -`overlays/` participates in this feature. - -Do not introduce a prohibition against a directory named `profiles`, literal -strings containing "profile", or application fields such as `model_profile`. -Ordinary explicit imports retain their existing rules regardless of a file's -directory name. Removing legacy selection support does not reserve or ban these -names elsewhere. - -## Internal architecture and maintained examples - -The public facade retains root-owned named types and inward-only dependencies. -Selected-file acquisition and the existing overlay merge mechanism remain -distinct responsibilities within the engine. Internal naming reflects the new -terminology without a pipeline or merge-algorithm redesign. - -Maintained examples use the new API and selected-file directory. The Narratio -example still has an application-owned `ModelProfile` / `model_profile`; that -concept is deliberately independent of ConfigKit overlay selection. - -## Documentation and migration contract - -Current public comments, configuration documentation, consumer guidance, -architecture terminology, and implementation descriptions consistently call the -feature an overlay. Accepted ADR decisions and historical release notes retain -their historical wording. The extraction inventory preserves literal upstream -symbols and revision-specific evidence while distinguishing current ConfigKit -implementation names. Historical navigation links may change when their targets -move; historical decisions do not change. - -Consumer migration consists of updating Go identifiers, moving selected files to -`overlays/`, and updating any handling of the renamed provenance-layer and -diagnostic-code strings. Applications own any corresponding CLI flag rename; -ConfigKit does not define or modify downstream CLIs. - -The future minor-release note must explain those four breaking surfaces. Release -preparation and publication remain governed by the [release procedure](../release.md); -this roadmap does not create a release candidate or introduce additional gates. - -## Completion criteria - -The rename is complete when all maintained consumers compile against the new -API, selection and reporting consistently use the new terminology, the legacy -feature surface is absent, and regression coverage confirms unchanged semantics -and no directory fallback. Remaining "profile" references must be intentional -application data, migration explanation, or historical records, not active -ConfigKit feature names. - -Tests establish the new selection directory, absence of legacy fallback, and -preservation of existing semantics. Existing coverage remains with its owning -layer instead of being duplicated across the facade and engine. - -After implementation, current contracts own the renamed behavior and this -roadmap becomes a completion record for retirement during release preparation. +Application-owned profile concepts, including `ModelProfile` and `model_profile`, +remain unchanged. A future v0.4.0 release-preparation task must describe all four +breaking surfaces. Versioning, release notes, tagging, publishing, dependency +updates, and downstream changes are not part of this completed implementation. diff --git a/examples/consumers/consumers_test.go b/examples/consumers/consumers_test.go index d118ad8..d77590d 100644 --- a/examples/consumers/consumers_test.go +++ b/examples/consumers/consumers_test.go @@ -97,6 +97,9 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) { overlaid.Config.ModelProfile != "concise" || len(overlaid.Sources()) != 3 { t.Fatalf("configuration overlay and application model profile = %#v, %v", overlaid, err) } + if source := overlaid.Sources()[2]; source.Label != "overlays/testing.yml" || source.Layer != configkit.LayerOverlay { + t.Fatalf("Narratio overlay source = %#v", source) + } notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml")) window, converted := consumers.NotariusWindow(notarius.Config, 0) diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index 0f4766f..a53a89d 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -49,6 +49,12 @@ func TestExplainShowsDefaultBaseAndEffectiveOverlayValue(t *testing.T) { explanation.Origin.Locations[0].Line != 2 { t.Fatalf("effective explanation = %#v", explanation) } + var rendered bytes.Buffer + if err := result.WriteExplanation(&rendered, Keys("transcription", "concurrency")); err != nil || + !strings.Contains(rendered.String(), `layer="overlay"`) || + !strings.Contains(rendered.String(), `label="overlays/testing.yml"`) { + t.Fatalf("rendered explanation = %v\n%s", err, rendered.String()) + } } type explanationConfig struct { diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 7630103..ef39712 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -89,6 +89,18 @@ settings: } } +func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n") + writeImportFile(t, directory, "profiles/shared.yml", "settings: {root: imported}\n") + + result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) + if err != nil || result.Config.Settings.Root != "imported" || len(result.Sources()) != 2 || + result.Sources()[1].Layer != LayerBase { + t.Fatalf("profiles import = %#v, %v", result, err) + } +} + func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) { t.Run("all duplicate claimants", func(t *testing.T) { directory := t.TempDir() diff --git a/internal/engine/overlay.go b/internal/engine/overlay.go index 3c5b2f0..aedbae3 100644 --- a/internal/engine/overlay.go +++ b/internal/engine/overlay.go @@ -21,7 +21,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping { return nil, []Diagnostic{{ Code: CodeOverlayInvalid, Severity: SeverityError, - Message: "overlay overlay requires mapping document roots", + Message: "overlay requires mapping document roots", }} } result := cloneComposedDocument(base) diff --git a/internal/engine/selected_overlay_test.go b/internal/engine/selected_overlay_test.go index 779284b..300d37c 100644 --- a/internal/engine/selected_overlay_test.go +++ b/internal/engine/selected_overlay_test.go @@ -98,6 +98,7 @@ func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) { directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n") writeImportFile(t, directory, "overlays/testing.yml", "settings: {changed: selected}\n") + writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: legacy}\n") writeImportFile(t, directory, "overlays/default.yml", "settings: {changed: forbidden}\n") for _, name := range []string{"", "default"} { @@ -107,10 +108,18 @@ func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) { } } selected, err := testLoad[overlayTestConfig](File(mainPath), Options[overlayTestConfig]{Overlay: "testing"}) - if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 { + if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 || + selected.Sources()[1].Label != "overlays/testing.yml" || selected.Sources()[1].Layer != LayerOverlay { t.Fatalf("selected overlay = %#v, %v", selected, err) } + t.Run("legacy directory is not selected", func(t *testing.T) { + legacyDirectory := t.TempDir() + legacyMain := writeImportFile(t, legacyDirectory, "main.yml", "settings: {kept: base}\n") + writeImportFile(t, legacyDirectory, "profiles/testing.yml", "settings: {changed: legacy}\n") + requireOverlayLoadCode(t, File(legacyMain), Options[overlayTestConfig]{Overlay: "testing"}, CodeOverlayMissing) + }) + for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} { t.Run(name, func(t *testing.T) { requireOverlayLoadCode(t, File(mainPath), Options[overlayTestConfig]{Overlay: name}, CodeOptionsInvalid) diff --git a/records_test.go b/records_test.go new file mode 100644 index 0000000..7755e3b --- /dev/null +++ b/records_test.go @@ -0,0 +1,22 @@ +package configkit + +import "testing" + +func TestOverlayPublicValues(t *testing.T) { + if LayerOverlay != "overlay" { + t.Fatalf("LayerOverlay = %q", LayerOverlay) + } + for code, value := range map[Code]string{ + CodeOverlayInvalid: "overlay.invalid", + CodeOverlayMissing: "overlay.missing", + CodeOverlayOutsideRoot: "overlay.outside_root", + CodeOverlayRepeated: "overlay.repeated", + CodeOverlayDirective: "overlay.directive", + CodeOverlayVersion: "overlay.version", + CodeOverlayLimitExceeded: "overlay.limit_exceeded", + } { + if string(code) != value { + t.Errorf("code = %q, want %q", code, value) + } + } +}