Resolve ConfigKit paths from declaration origins

This commit is contained in:
2026-09-13 16:48:44 +00:00
parent 69295d72c9
commit 320de32a61
7 changed files with 326 additions and 10 deletions

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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) {