diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 81695bf1..3fa165bf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -356,6 +356,8 @@ Test rendering failure and caller mutation at these real local boundaries. ## Stage 7 — Resolve Declaration-Relative Paths +**Status:** Completed 2026-09-13. + **Goal:** Normalize file paths using their effective source origins and retain correct ConfigKit inspection history. diff --git a/internal/core/config/document_paths.go b/internal/core/config/document_paths.go new file mode 100644 index 00000000..cf6f1788 --- /dev/null +++ b/internal/core/config/document_paths.go @@ -0,0 +1,165 @@ +package config + +import ( + "path/filepath" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func normalizeDocumentPaths(document *Document, resolution *configkit.Resolution, state *documentLoadState) { + if document == nil { + return + } + initializeDocumentMainDirectory(resolution, state) + if document.PromptKit != nil { + normalizeDocumentPath(resolution, state, configkit.Keys("promptkit", "profile_dir"), &document.PromptKit.ProfileDir, false, "resolved PromptKit profile directory") + normalizeDocumentPath(resolution, state, configkit.Keys("promptkit", "profile_file"), &document.PromptKit.ProfileFile, false, "resolved PromptKit profile file") + } + if document.Output != nil { + normalizeDocumentPath(resolution, state, configkit.Keys("output", "directory"), &document.Output.Directory, false, "resolved output directory") + } + if document.Cache != nil { + if document.Cache.ChunkPlans != nil { + normalizeDocumentPath(resolution, state, configkit.Keys("cache", "chunk_plans", "directory"), &document.Cache.ChunkPlans.Directory, true, "resolved chunk-plan cache directory") + } + if document.Cache.Checkpoints != nil { + normalizeDocumentPath(resolution, state, configkit.Keys("cache", "checkpoints", "directory"), &document.Cache.Checkpoints.Directory, true, "resolved checkpoint cache directory") + } + } + if document.Debug != nil { + normalizeDocumentPath(resolution, state, configkit.Keys("debug", "directory"), &document.Debug.Directory, false, "resolved debug directory") + } + for _, id := range sortedPipelineDocumentKeys(document.Pipelines) { + profile := document.Pipelines[id] + path := configkit.Keys("pipelines").Key(id) + normalizeDocumentReferencePaths(profile.References, path.Key("references"), resolution, state) + normalizeDocumentBindingReferencePaths(&profile.Input, path.Key("input"), resolution, state) + if profile.Chunk != nil { + normalizeDocumentBindingReferencePaths(profile.Chunk, path.Key("chunk"), resolution, state) + } + if profile.Output != nil { + normalizeDocumentBindingReferencePaths(profile.Output, path.Key("output"), resolution, state) + } + for laneID, lane := range profile.Artifacts { + normalizeDocumentLaneReferencePaths(&lane, path.Key("artifacts").Key(laneID), resolution, state) + profile.Artifacts[laneID] = lane + } + for index := range profile.Steps { + step := &profile.Steps[index] + stepPath := path.Key("steps").Index(index) + normalizeDocumentReferencePaths(step.References, stepPath.Key("references"), resolution, state) + for laneID, lane := range step.Artifacts { + normalizeDocumentLaneReferencePaths(&lane, stepPath.Key("artifacts").Key(laneID), resolution, state) + step.Artifacts[laneID] = lane + } + } + document.Pipelines[id] = profile + } +} + +func initializeDocumentMainDirectory(resolution *configkit.Resolution, state *documentLoadState) { + if state == nil || state.mainDirectory != "" { + return + } + info, ok := resolution.InputInfo(configkit.Keys("version")) + if ok && len(info.Origin.Locations) == 1 { + if source, sourceOK := resolution.Source(info.Origin.Locations[0].SourceID); sourceOK && source.Path != "" { + state.mainDirectory = filepath.Dir(source.Path) + return + } + } + // Byte and reader sources are test-oriented private entry points. They have + // no declaring file, so preserve their established invocation-directory + // semantics without extending that fallback to file-backed production loads. + state.mainDirectory = cleanInvocationDirectory(".") +} + +func normalizeDocumentBindingReferencePaths(binding *BindingDocument, path configkit.Path, resolution *configkit.Resolution, state *documentLoadState) { + if binding != nil { + normalizeDocumentReferencePaths(binding.References, path.Key("references"), resolution, state) + } +} + +func normalizeDocumentLaneReferencePaths(lane *ArtifactLaneDocument, path configkit.Path, resolution *configkit.Resolution, state *documentLoadState) { + if lane == nil { + return + } + normalizeDocumentBindingReferencePaths(&lane.Extract, path.Key("extract"), resolution, state) + normalizeDocumentBindingReferencePaths(lane.Merge, path.Key("merge"), resolution, state) + normalizeDocumentBindingReferencePaths(lane.Normalize, path.Key("normalize"), resolution, state) +} + +func normalizeDocumentReferencePaths(references map[string]ReferenceDocument, path configkit.Path, resolution *configkit.Resolution, state *documentLoadState) { + for _, name := range sortedReferenceDocumentKeys(references) { + source := references[name] + sourcePath := path.Key(name).Key("path") + if source.Path != nil && *source.Path != "" && !filepath.IsAbs(*source.Path) { + base, ok := documentPathBase(resolution, state, sourcePath) + if !ok { + resolution.Error(sourcePath, "relative path has no unambiguous source base") + } else { + value := filepath.Clean(filepath.Join(base, *source.Path)) + if value != *source.Path { + resolution.Record(sourcePath, configkit.ChangeNormalization, "resolved external reference path") + source.Path = documentValue(value) + } + } + references[name] = source + } + } +} + +func normalizeDocumentPath(resolution *configkit.Resolution, state *documentLoadState, path configkit.Path, target **string, optional bool, reason string) { + if target == nil || *target == nil || **target == "" || filepath.IsAbs(**target) { + return + } + base, ok := documentPathBase(resolution, state, path) + if !ok { + resolution.Error(path, "relative path has no unambiguous source base") + return + } + value := filepath.Clean(filepath.Join(base, **target)) + if optional && **target == "" { + return + } + if *target != nil && **target != value { + configkit.Normalize(resolution, path, target, documentValue(value), reason) + } +} + +func documentPathBase(resolution *configkit.Resolution, state *documentLoadState, path configkit.Path) (string, bool) { + original := path + if state != nil { + original = state.originalPath(path) + } + info, ok := resolution.InputInfo(original) + if !ok { + if state != nil && state.mainDirectory != "" { + return state.mainDirectory, true + } + return "", false + } + switch info.Origin.Layer { + case configkit.LayerCLI: + if state != nil && state.invocationDirectory != "" { + return state.invocationDirectory, true + } + case configkit.LayerDefault: + if state != nil && state.mainDirectory != "" { + return state.mainDirectory, true + } + default: + if len(info.Origin.Locations) != 1 { + return "", false + } + source, ok := resolution.Source(info.Origin.Locations[0].SourceID) + if !ok || source.Path == "" { + if state != nil && state.mainDirectory != "" { + return state.mainDirectory, true + } + return "", false + } + return filepath.Dir(source.Path), true + } + return "", false +} diff --git a/internal/core/config/document_paths_test.go b/internal/core/config/document_paths_test.go new file mode 100644 index 00000000..10a5f7cc --- /dev/null +++ b/internal/core/config/document_paths_test.go @@ -0,0 +1,123 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestDocumentPathsUseDeclaringFilesAcrossImports(t *testing.T) { + t.Parallel() + + root := t.TempDir() + main := filepath.Join(root, "notarius.yml") + fragmentDirectory := filepath.Join(root, "fragments") + if err := os.MkdirAll(fragmentDirectory, 0o700); err != nil { + t.Fatal(err) + } + fragment := filepath.Join(fragmentDirectory, "pipelines.yml") + if err := os.WriteFile(fragment, []byte(`pipelines: + imported: + input: {module: input} + references: + source: {path: ./from-fragment.txt} +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(`version: 5 +configkit: + imports: + - path: fragments/pipelines.yml +output: {directory: ./output} +debug: {directory: ./debug} +pipelines: + main: + input: {module: input} + references: + source: {path: ./from-main.txt} +`), 0o600); err != nil { + t.Fatal(err) + } + + loaded, err := loadConfig(main, LoadOptions{}) + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + if got := loaded.Config.Pipelines["main"].References["source"].Path; got != filepath.Join(root, "from-main.txt") { + t.Fatalf("main reference = %q", got) + } + if got := loaded.Config.Pipelines["imported"].References["source"].Path; got != filepath.Join(fragmentDirectory, "from-fragment.txt") { + t.Fatalf("fragment reference = %q", got) + } + if loaded.Config.Output.Directory != filepath.Join(root, "output") || loaded.Config.Debug.Directory != filepath.Join(root, "debug") { + t.Fatalf("destinations = %#v %#v", loaded.Config.Output, loaded.Config.Debug) + } +} + +func TestDocumentPathsUseInvocationDirectoryForOverrides(t *testing.T) { + t.Parallel() + + root := t.TempDir() + configPath := filepath.Join(root, "notarius.yml") + if err := os.WriteFile(configPath, []byte("version: 5\n"), 0o600); err != nil { + t.Fatal(err) + } + invocation := t.TempDir() + loaded, err := loadConfig(configPath, LoadOptions{ + InvocationDirectory: invocation, + Overrides: []configkit.Override{{Path: configkit.Keys("output", "directory"), Value: "generated", Flag: "--output"}}, + }) + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + if got := loaded.Config.Output.Directory; got != filepath.Join(invocation, "generated") { + t.Fatalf("override directory = %q", got) + } +} + +func TestDocumentPathsUseProfileDeclarationDirectory(t *testing.T) { + t.Parallel() + + root := t.TempDir() + profiles := filepath.Join(root, "profiles") + if err := os.MkdirAll(profiles, 0o700); err != nil { + t.Fatal(err) + } + main := filepath.Join(root, "notarius.yml") + if err := os.WriteFile(main, []byte("version: 5\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profiles, "development.yml"), []byte("output: {directory: ./profile-output}\n"), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := loadConfig(main, LoadOptions{Profile: "development"}) + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + if got := loaded.Config.Output.Directory; got != filepath.Join(profiles, "profile-output") { + t.Fatalf("profile directory = %q", got) + } +} + +func TestDocumentPathsKeepEmptyCacheDirectoriesAndAbsolutePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + configPath := filepath.Join(root, "notarius.yml") + abs := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(configPath, []byte("version: 5\ncache:\n chunk_plans: {directory: ''}\n checkpoints: {directory: ''}\npipelines:\n main:\n input: {module: input}\n references:\n outside: {path: "+abs+"}\n"), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := loadConfig(configPath, LoadOptions{}) + if err != nil { + t.Fatalf("loadConfig() error = %v", err) + } + if loaded.Config.Cache.ChunkPlans.Directory != "" || loaded.Config.Cache.Checkpoints.Directory != "" { + t.Fatalf("cache directories = %#v", loaded.Config.Cache) + } + if got := loaded.Config.Pipelines["main"].References["outside"].Path; got != abs { + t.Fatalf("absolute reference = %q", got) + } +} diff --git a/internal/core/config/load.go b/internal/core/config/load.go index cfcbb065..9f1b6b56 100644 --- a/internal/core/config/load.go +++ b/internal/core/config/load.go @@ -1,6 +1,8 @@ package config import ( + "path/filepath" + configkit "gitea.maximumdirect.net/eric/ConfigKit" ) @@ -8,26 +10,43 @@ import ( // boundary. It is intentionally narrow: configuration discovery and CLI flag // parsing remain outside this package. type LoadOptions struct { - Profile string - Overrides []configkit.Override + Profile string + Overrides []configkit.Override + InvocationDirectory string } // loadDocument is the private ConfigKit load boundary under construction. // Production commands continue to use the existing version 4 loader until the // runtime conversion and command wiring are complete. func loadDocument(path string, options LoadOptions) (*configkit.Result[Document], error) { - return loadDocumentSource(configkit.File(path), options) + return loadDocumentWithSource(configkit.File(path), options, true) } func loadDocumentSource(source configkit.Source, options LoadOptions) (*configkit.Result[Document], error) { - state := newDocumentLoadState() + return loadDocumentWithSource(source, options, false) +} + +func loadDocumentWithSource(source configkit.Source, options LoadOptions, imports bool) (*configkit.Result[Document], error) { + state := newDocumentLoadState(options.InvocationDirectory) return configkit.Load(source, configkit.Options[Document]{ Definition: documentDefinition(state), + Imports: imports, Profile: options.Profile, Overrides: options.Overrides, }) } +func cleanInvocationDirectory(value string) string { + if value == "" { + return "" + } + absolute, err := filepath.Abs(value) + if err != nil { + return "" + } + return filepath.Clean(absolute) +} + func documentDefinition(state *documentLoadState) configkit.Definition[Document] { return configkit.Definition[Document]{ Defaults: documentDefaults, diff --git a/internal/core/config/operational_document.go b/internal/core/config/operational_document.go index 01067a6a..2d18d0fd 100644 --- a/internal/core/config/operational_document.go +++ b/internal/core/config/operational_document.go @@ -17,6 +17,7 @@ func resolveDocumentOperations(document *Document, resolution *configkit.Resolut normalizeDocumentPromptKit(document.PromptKit, resolution) normalizeDocumentState(document, resolution) normalizeDocumentPipelines(document, resolution, state) + normalizeDocumentPaths(document, resolution, state) deriveDocumentExtractWorkers(document, resolution) return nil } diff --git a/internal/core/config/operational_document_test.go b/internal/core/config/operational_document_test.go index ea57ddcd..d15f5355 100644 --- a/internal/core/config/operational_document_test.go +++ b/internal/core/config/operational_document_test.go @@ -36,16 +36,20 @@ debug: if loaded.Config.Concurrency.TotalLLM == nil || *loaded.Config.Concurrency.TotalLLM != 4 || loaded.Config.Concurrency.StageWorkers["extract"] != 4 { t.Fatalf("concurrency = %#v", loaded.Config.Concurrency) } - if loaded.Config.PromptKit.ProfileDir == nil || *loaded.Config.PromptKit.ProfileDir != "./profiles" || + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if loaded.Config.PromptKit.ProfileDir == nil || *loaded.Config.PromptKit.ProfileDir != filepath.Join(workingDirectory, "profiles") || loaded.Config.PromptKit.LocalBackend == nil || loaded.Config.PromptKit.LocalBackend.Endpoint == nil || *loaded.Config.PromptKit.LocalBackend.Endpoint != "https://localhost:8080/v1" || loaded.Config.PromptKit.LocalBackend.ConcurrencyLimit == nil || *loaded.Config.PromptKit.LocalBackend.ConcurrencyLimit != 0 { t.Fatalf("promptkit = %#v", loaded.Config.PromptKit) } - if loaded.Config.Output.Directory == nil || *loaded.Config.Output.Directory != "./output" || + if loaded.Config.Output.Directory == nil || *loaded.Config.Output.Directory != filepath.Join(workingDirectory, "output") || loaded.Config.Cache.ChunkPlans.Mode == nil || *loaded.Config.Cache.ChunkPlans.Mode != "refresh" || loaded.Config.Cache.ChunkPlans.Directory == nil || *loaded.Config.Cache.ChunkPlans.Directory != "" || loaded.Config.Cache.Checkpoints.Directory == nil || *loaded.Config.Cache.Checkpoints.Directory != "" || - loaded.Config.Debug.Directory == nil || *loaded.Config.Debug.Directory != "./debug" { + loaded.Config.Debug.Directory == nil || *loaded.Config.Debug.Directory != filepath.Join(workingDirectory, "debug") { t.Fatalf("state = %#v, %#v, %#v", loaded.Config.Output, loaded.Config.Cache, loaded.Config.Debug) } if diagnostics := loaded.Diagnostics(); len(diagnostics) != 0 { diff --git a/internal/core/config/pipeline_document.go b/internal/core/config/pipeline_document.go index 8c283ec4..4a56f333 100644 --- a/internal/core/config/pipeline_document.go +++ b/internal/core/config/pipeline_document.go @@ -13,11 +13,13 @@ import ( // load-local: source-location handling consumes it during a later resolver // pass and it never becomes runtime configuration state. type documentLoadState struct { - originalPaths map[string]configkit.Path + originalPaths map[string]configkit.Path + mainDirectory string + invocationDirectory string } -func newDocumentLoadState() *documentLoadState { - return &documentLoadState{originalPaths: make(map[string]configkit.Path)} +func newDocumentLoadState(invocationDirectory string) *documentLoadState { + return &documentLoadState{originalPaths: make(map[string]configkit.Path), invocationDirectory: cleanInvocationDirectory(invocationDirectory)} } func (state *documentLoadState) rememberPath(normalized, original configkit.Path) {