Skip to content

Commit e98a7c5

Browse files
ostermanclaude
andcommitted
fix(config): fix 8 DX bugs found field-testing the --config/--profile precedence changes
A field-test pass against this branch's config-loading changes (multi-file --config merging, profile precedence, config get/set) found 8 real, live-reproduced issues and fixes them all, each with a failing test committed first: - config set/delete/format silently edited only the FIRST --config file while config get reported the fully-merged value -- a false-success bug whenever a later file also set the same key. Now refuses ambiguous multi-file --config with a clear error (pkg/config/config_edit.go: new ErrAmbiguousConfigFile/ResolveConfigOverride, shared by cmd/config/operations.go and the duplicate in pkg/mcp/config/config.go). - ATMOS_CONFIG/ATMOS_CONFIG_PATH with multiple comma-separated values worked for some commands (via pkg/config's own os.Args/env fallback) but broke ~40 others reading these flags through pkg/flags' Viper-based ParseGlobalFlags, which splits env-sourced values on whitespace, not commas. Fixed once at that shared choke point by exporting the existing --profile fix (parseViperProfilesFromEnv -> cfg.FixViperEnvStringSliceQuirk) and applying it there too. - profiles.base_path declared in a non-first --config file resolved against the FIRST file's directory regardless of which file actually declared it, silently failing to find profiles that exist. Added per-file directory tracking (mirroring the existing base_path tracking in mergeFiles) threaded through to discoverProfileLocations. - Vendor/workflow error messages (ErrEmptySources, ErrMissingVendorConfigDefinition, ErrDuplicateComponents, ErrComponentNotDefined, ErrNoComponentsWithTags, and others) still leaked absolute paths right next to the "Vendoring from" line already fixed in the prior commit -- a half-fixed pattern. Wrapped 13 sites in displayPath(), plus fixed a copy/paste bug in one workflow directory-read error that showed the raw unresolved config value instead of the path actually searched. - displayPath() itself was silently defeated whenever the working directory was reached through a symlink (e.g. macOS's /tmp), because os.Getwd() preserves the logical $PWD path while git-root-discovery-resolved config paths are physical. Fixed with a two-attempt comparison (raw first, then both sides resolved via the directory, since the target file often doesn't exist yet). - --config-path always wins over --config regardless of CLI argument order (undocumented, now documented, not code-changed -- effort didn't justify a fix for this ordering nuance). - Documented the previously-undocumented ATMOS_CONFIG/ATMOS_CONFIG_PATH env vars and fixed --config's flag-type description on config-set/delete/format.mdx (all three incorrectly said "string" instead of "string slice"). Also updates the field-test skill to default to testing the current branch's diff against its base branch when no explicit target is given, instead of asking. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 571f0d2 commit e98a7c5

27 files changed

Lines changed: 732 additions & 96 deletions

.claude/skills/field-test/SKILL.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: field-test
3-
description: "Hands-on manual DX test pass of a feature or CLI command: read the real implementation and tests, hypothesize plausible user misunderstandings and misuse automated tests don't cover, build durable fixtures, execute for real against real state, and report ranked findings. Investigation only — never fixes anything found. Invoke on explicit requests like 'field test X' / 'do a DX test pass on X' / 'find vibe-coded slop in X'."
4-
argument-hint: "Feature or command to test, e.g. 'atmos vendor pull'"
3+
description: "Hands-on manual DX test pass of a feature or CLI command: read the real implementation and tests, hypothesize plausible user misunderstandings and misuse automated tests don't cover, build durable fixtures, execute for real against real state, and report ranked findings. Investigation only — never fixes anything found. Defaults to testing whatever the current branch changed vs its base branch when no explicit target is given. Invoke on explicit requests like 'field test X' / 'do a DX test pass on X' / 'find vibe-coded slop in X' / 'field test this branch'."
4+
argument-hint: "Feature or command to test, e.g. 'atmos vendor pull' (omit to default to this branch's change)"
55
metadata:
66
copyright: Copyright Cloud Posse, LLC 2026
77
version: "1.0.0"
@@ -10,8 +10,17 @@ metadata:
1010
# Field Test
1111

1212
Hands-on, adversarial test pass of **`$ARGUMENTS`** (the feature/command named when this skill
13-
was invoked, e.g. `atmos vendor pull`). If no target was given, ask which feature/command to test
14-
before starting.
13+
was invoked, e.g. `atmos vendor pull`).
14+
15+
**If no target was given, default to the change introduced on the current branch** rather than
16+
asking. Determine the base branch (the upstream tracking branch via
17+
`git rev-parse --abbrev-ref --symbolic-full-name @{u}`, falling back to `origin/main`/`main` if
18+
no upstream is configured), then run `git diff <base>...HEAD --stat` (and the full diff for
19+
context) to see what actually changed. Derive the test target from that diff — the CLI
20+
command(s), flag(s), config option(s), or subsystem the changed files implement — and state
21+
explicitly what you inferred and why before proceeding to Phase 1. Only fall back to asking the
22+
user if the diff is empty (nothing to test) or spans multiple unrelated features with no coherent
23+
single target (ask which one to focus on, don't silently pick one).
1524

1625
Goal: catch "vibe-coded slop" — behavior that looks fine in code review but breaks or misleads a
1726
real user — not to re-run what automated tests already cover. Anticipate plausible user
@@ -27,10 +36,18 @@ The goal is a map of "documented or plausible usage" minus "already tested" = wh
2736
verification. This phase is broad, read-only research — delegate it to `Agent subagent_type:
2837
"Explore"` (1-3 agents in parallel, one per bullet below) rather than doing it all serially inline.
2938

39+
When defaulting to the current branch (no explicit target given), scope every bullet below to the
40+
target inferred from the branch diff — don't research the whole surrounding subsystem when the
41+
branch only touched one corner of it. If the branch's changed files span more than one command or
42+
package, treat each as a separate target to cover in Phase 2-4, prioritized by how much of the
43+
diff each accounts for.
44+
3045
- **Implementation** — the actual code, not just its docs or the skill describing it. Per this
3146
repo's conventions, business logic lives in narrow `pkg/` packages, not `internal/exec/` (being
3247
phased out) — check both `cmd/<command>/` (thin call site) and the `pkg/` package(s) it
33-
delegates to for the real logic and error paths.
48+
delegates to for the real logic and error paths. When defaulting from a branch diff, read the
49+
diff itself first (not just the post-change files) — the diff shows what changed *from*,
50+
which is exactly where a regression or half-finished edge case would show up.
3451
- **Docs and skills** — every relevant page under `website/docs/cli/commands/`, the matching
3552
`.claude/skills/atmos-*` skill(s) for the subsystem, and any README describing the feature. Note
3653
anything phrased with confidence you haven't independently confirmed against the code — docs and

cmd/config/operations.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,16 @@ func init() {
158158
}
159159

160160
// resolveConfigFile picks the atmos.yaml to edit. The inherited persistent
161-
// --config flag (first entry) acts as an explicit override; otherwise the file
162-
// is discovered in the current directory or git root.
161+
// --config flag acts as an explicit override when it names exactly one file;
162+
// otherwise the file is discovered in the current directory or git root.
163163
func resolveConfigFile(cmd *cobra.Command) (string, error) {
164-
override := ""
165-
if cfgFiles, _ := cmd.Flags().GetStringSlice("config"); len(cfgFiles) > 0 {
166-
override = cfgFiles[0]
164+
cfgFiles, _ := cmd.Flags().GetStringSlice("config")
165+
override, err := cfg.ResolveConfigOverride(cfgFiles)
166+
if err != nil {
167+
return "", errUtils.Build(errUtils.ErrInvalidArgumentError).
168+
WithExplanation(err.Error()).
169+
WithHint("Pass a single --config file to config set/delete/format, or edit the target file directly.").
170+
Err()
167171
}
168172

169173
file, err := cfg.ResolveEditableConfigFile(atmosConfigPtr, override)

cmd/config/operations_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"testing"
1010

11+
ckerrors "github.com/cockroachdb/errors"
1112
"github.com/spf13/cobra"
1213
"github.com/spf13/viper"
1314
"github.com/stretchr/testify/assert"
@@ -136,6 +137,29 @@ func TestResolveConfigFile_Error(t *testing.T) {
136137
require.ErrorIs(t, err, errUtils.ErrInvalidArgumentError)
137138
}
138139

140+
// TestResolveConfigFile_MultipleConfigFilesAmbiguous guards against a bug found during a
141+
// field-test pass on cloudposse/atmos#2867/#2868: resolveConfigFile silently used only the
142+
// FIRST --config file (cfgFiles[0]) when multiple were given, so `config set --config a,b
143+
// logs.level X` reported success editing a.yaml while the actual effective value (what `config
144+
// get` reports, and what every other atmos command uses) stayed unchanged whenever b.yaml also
145+
// set that key -- a false success, not just a stale-value bug.
146+
func TestResolveConfigFile_MultipleConfigFilesAmbiguous(t *testing.T) {
147+
dir := t.TempDir()
148+
fileA := filepath.Join(dir, "a.yaml")
149+
fileB := filepath.Join(dir, "b.yaml")
150+
require.NoError(t, os.WriteFile(fileA, []byte("settings:\n enabled: true\n"), 0o644))
151+
require.NoError(t, os.WriteFile(fileB, []byte("settings:\n enabled: false\n"), 0o644))
152+
153+
cmd := &cobra.Command{}
154+
cmd.Flags().StringSlice("config", []string{fileA, fileB}, "")
155+
156+
_, err := resolveConfigFile(cmd)
157+
require.ErrorIs(t, err, errUtils.ErrInvalidArgumentError)
158+
details := strings.Join(ckerrors.GetAllDetails(err), "\n")
159+
assert.Contains(t, details, "a.yaml")
160+
assert.Contains(t, details, "b.yaml")
161+
}
162+
139163
func TestConfigGetCommand_MissingValue(t *testing.T) {
140164
dir := t.TempDir()
141165
file := filepath.Join(dir, "atmos.yaml")

internal/exec/describe_workflows_test.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ workflows:
125125
},
126126
},
127127
wantErr: true,
128-
errContains: "the workflow directory 'nonexistent' does not exist",
128+
errContains: "workflow directory does not exist: 'nonexistent'",
129129
},
130130
}
131131

@@ -362,3 +362,29 @@ func TestExecuteDescribeWorkflows_EmptyWorkflowsDirectory(t *testing.T) {
362362
assert.Len(t, mapResult, 0)
363363
assert.Len(t, allResult, 0)
364364
}
365+
366+
// TestExecuteDescribeWorkflows_PathLeak guards against a bug found during a field-test pass on
367+
// cloudposse/atmos#2867/#2868: "the workflow directory '%s' does not exist" interpolated the
368+
// resolved absolute workflowsDir directly, and the sibling "error reading the directory" message
369+
// interpolated the raw, unresolved atmosConfig.Workflows.BasePath instead of workflowsDir (the
370+
// directory actually searched) -- a copy/paste inconsistency with the sibling branch one line up.
371+
// The pre-existing "nonexistent workflows directory" test above doesn't catch either bug: it
372+
// leaves BasePath/WorkflowsDirAbsolutePath unset, so getWorkflowsDirToUse falls back to a bare
373+
// relative path that was never absolute to begin with.
374+
func TestExecuteDescribeWorkflows_PathLeak(t *testing.T) {
375+
resolvedDir := resolvedTempDir(t)
376+
377+
config := schema.AtmosConfiguration{
378+
BasePath: resolvedDir,
379+
Workflows: schema.Workflows{
380+
BasePath: "nonexistent-workflows-dir",
381+
},
382+
}
383+
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))
384+
385+
_, _, _, err := ExecuteDescribeWorkflows(config)
386+
387+
require.Error(t, err)
388+
assert.NotContains(t, err.Error(), resolvedDir, "error must not leak the machine-specific absolute directory")
389+
assert.Contains(t, err.Error(), "nonexistent-workflows-dir")
390+
}

internal/exec/validate_schema.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,11 +344,37 @@ func displayPath(file string) string {
344344
if err != nil {
345345
return file
346346
}
347+
if rel, ok := relPath(cwd, file); ok {
348+
return rel
349+
}
350+
// os.Getwd() preserves the logical $PWD-style path (e.g. macOS's /tmp), while
351+
// config-derived absolute paths resolved through git-root discovery resolve symlinks
352+
// internally (via go-git/go-billy's filepath.EvalSymlinks) -- so cwd and file can end up in
353+
// different (logical vs. physical) forms of the same directory when a symlink is involved.
354+
// Retry with both resolved to their canonical physical form; EvalSymlinks on an
355+
// already-physical path is a no-op, so this only changes behavior for the mismatched case.
356+
// Resolve the DIRECTORY, not file itself: file frequently doesn't exist yet (that's often
357+
// exactly why displayPath is being called, e.g. "file not found" errors), and EvalSymlinks
358+
// requires its target to exist.
359+
resolvedCwd, cwdErr := filepath.EvalSymlinks(cwd)
360+
resolvedFileDir, fileErr := filepath.EvalSymlinks(filepath.Dir(file))
361+
if cwdErr != nil || fileErr != nil {
362+
return file
363+
}
364+
if rel, ok := relPath(resolvedCwd, filepath.Join(resolvedFileDir, filepath.Base(file))); ok {
365+
return rel
366+
}
367+
return file
368+
}
369+
370+
// relPath returns file relative to cwd when that relative path stays within cwd (no ".."
371+
// prefix), and whether it succeeded.
372+
func relPath(cwd, file string) (string, bool) {
347373
rel, err := filepath.Rel(cwd, file)
348374
if err != nil || strings.HasPrefix(rel, "..") {
349-
return file
375+
return "", false
350376
}
351-
return rel
377+
return rel, true
352378
}
353379

354380
func (av *atmosValidatorExecutor) printValidation(schema string, files []string) (uint, error) {

internal/exec/validate_schema_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,73 @@ func TestDisplayPath(t *testing.T) {
280280
}
281281
}
282282

283+
// TestDisplayPath_SymlinkedCWD guards against a bug found during a field-test pass on
284+
// cloudposse/atmos#2867/#2868: when the working directory is reached through a symlink (e.g.
285+
// macOS's /tmp -> /private/tmp) AND the shell's $PWD reflects the symlinked (logical) path,
286+
// os.Getwd() returns that logical path (Go's documented $PWD shortcut) while config-derived
287+
// absolute paths (BasePathAbsolute/VendorDirAbsolutePath/WorkflowsDirAbsolutePath, resolved via
288+
// git-root discovery which internally calls filepath.EvalSymlinks) return the physical,
289+
// resolved path -- so filepath.Rel between the two silently fails, and displayPath falls back
290+
// to the full absolute path, defeating the fix in exactly the environments it was meant to help.
291+
//
292+
// Plain os.Chdir alone doesn't reproduce this (it doesn't touch $PWD), so $PWD is set explicitly
293+
// here to model what a real shell `cd` into a symlinked directory does.
294+
func TestDisplayPath_SymlinkedCWD(t *testing.T) {
295+
tmpDir := t.TempDir()
296+
realDir := filepath.Join(tmpDir, "realdir")
297+
require.NoError(t, os.MkdirAll(realDir, 0o755))
298+
299+
symlinkPath := filepath.Join(tmpDir, "symlink")
300+
if err := os.Symlink(realDir, symlinkPath); err != nil {
301+
t.Skipf("Skipping symlink test: %v", err)
302+
}
303+
304+
resolvedRealDir, err := filepath.EvalSymlinks(realDir)
305+
require.NoError(t, err)
306+
307+
wd, err := os.Getwd()
308+
require.NoError(t, err)
309+
t.Cleanup(func() { require.NoError(t, os.Chdir(wd)) })
310+
require.NoError(t, os.Chdir(symlinkPath))
311+
t.Setenv("PWD", symlinkPath)
312+
313+
// file is built from the RESOLVED (physical) directory, as config-derived absolute paths
314+
// are (via git-root discovery's filepath.EvalSymlinks), while os.Getwd() inside displayPath
315+
// will see the symlinked (logical) form because $PWD matches it.
316+
file := filepath.Join(resolvedRealDir, "vendor.yaml")
317+
assert.Equal(t, "vendor.yaml", displayPath(file))
318+
}
319+
320+
// TestDisplayPath_SymlinkedCWD_BothLogical guards against a regression the first version of the
321+
// EvalSymlinks fix above introduced: when NO git-root discovery runs (e.g. base_path already
322+
// absolute, or no .git found), config-derived absolute paths are computed via plain
323+
// filepath.Abs, which -- like os.Getwd() -- respects the $PWD logical shortcut. So under a
324+
// symlinked cwd (e.g. macOS's /tmp) with no git repo involved, BOTH cwd and file stay
325+
// consistently in LOGICAL form and were already directly comparable before any EvalSymlinks
326+
// normalization. A fix that unconditionally resolves only cwd (not file) breaks this
327+
// previously-working case by making cwd physical while file stays logical.
328+
func TestDisplayPath_SymlinkedCWD_BothLogical(t *testing.T) {
329+
tmpDir := t.TempDir()
330+
realDir := filepath.Join(tmpDir, "realdir")
331+
require.NoError(t, os.MkdirAll(realDir, 0o755))
332+
333+
symlinkPath := filepath.Join(tmpDir, "symlink")
334+
if err := os.Symlink(realDir, symlinkPath); err != nil {
335+
t.Skipf("Skipping symlink test: %v", err)
336+
}
337+
338+
wd, err := os.Getwd()
339+
require.NoError(t, err)
340+
t.Cleanup(func() { require.NoError(t, os.Chdir(wd)) })
341+
require.NoError(t, os.Chdir(symlinkPath))
342+
t.Setenv("PWD", symlinkPath)
343+
344+
// file is built from the SAME logical (unresolved) symlink path as cwd, simulating
345+
// filepath.Abs's $PWD-shortcut behavior in the no-git-root-discovery case.
346+
file := filepath.Join(symlinkPath, "vendor.yaml")
347+
assert.Equal(t, "vendor.yaml", displayPath(file))
348+
}
349+
283350
func TestBuiltinConfigSchemaMatchesIncludesExistingOptionalDirectories(t *testing.T) {
284351
project := t.TempDir()
285352
require.NoError(t, os.MkdirAll(filepath.Join(project, "atmos.d"), 0o700))

internal/exec/vendor_utils.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func getConfigFiles(path string) ([]string, error) {
174174
return nil, ErrVendoringNotConfigured
175175
}
176176
if os.IsPermission(err) {
177-
return nil, fmt.Errorf("%w '%s'. Please check the file permissions", ErrPermissionDenied, path)
177+
return nil, fmt.Errorf("%w '%s'. Please check the file permissions", ErrPermissionDenied, displayPath(path))
178178
}
179179
return nil, fmt.Errorf("An error occurred while accessing the vendoring configuration: %w", err)
180180
}
@@ -186,7 +186,7 @@ func getConfigFiles(path string) ([]string, error) {
186186
}
187187

188188
if len(matches) == 0 {
189-
return nil, fmt.Errorf("%w '%s'", ErrNoYAMLConfigFiles, path)
189+
return nil, fmt.Errorf("%w '%s'", ErrNoYAMLConfigFiles, displayPath(path))
190190
}
191191
for i, match := range matches {
192192
matches[i] = filepath.Join(path, match)
@@ -218,7 +218,7 @@ func mergeVendorConfigFiles(configFiles []string) (schema.AtmosVendorConfig, err
218218
source := currentConfig.Spec.Sources[i]
219219
if source.Component != "" {
220220
if sourceMap[source.Component] {
221-
return vendorConfig, fmt.Errorf("%w '%s' found in config file '%s'", ErrDuplicateComponentsFound, source.Component, configFile)
221+
return vendorConfig, fmt.Errorf("%w '%s' found in config file '%s'", ErrDuplicateComponentsFound, source.Component, displayPath(configFile))
222222
}
223223
sourceMap[source.Component] = true
224224
}
@@ -247,7 +247,7 @@ func ExecuteAtmosVendorInternal(params *executeVendorOptions) error {
247247
// itself stays untouched (and possibly absolute) for the actual file/source resolution below.
248248
logInitialMessage(displayPath(params.vendorConfigFileName), params.tags)
249249
if len(params.atmosVendorSpec.Sources) == 0 && len(params.atmosVendorSpec.Imports) == 0 {
250-
return fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, params.vendorConfigFileName)
250+
return fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, displayPath(params.vendorConfigFileName))
251251
}
252252
// Process imports and return all sources from all the imports and from `vendor.yaml`.
253253
sources, _, err := processVendorImports(
@@ -262,7 +262,7 @@ func ExecuteAtmosVendorInternal(params *executeVendorOptions) error {
262262
}
263263

264264
if len(sources) == 0 {
265-
return fmt.Errorf("%w %s", ErrEmptySources, params.vendorConfigFileName)
265+
return fmt.Errorf("%w %s", ErrEmptySources, displayPath(params.vendorConfigFileName))
266266
}
267267

268268
if err := validateTagsAndComponents(sources, params.vendorConfigFileName, params.component, params.tags); err != nil {
@@ -306,7 +306,7 @@ func validateTagsAndComponents(
306306

307307
if len(lo.Intersect(tags, componentTags)) == 0 {
308308
return fmt.Errorf("%w '%s' tagged with the tags %v",
309-
ErrNoComponentsWithTags, vendorConfigFileName, tags)
309+
ErrNoComponentsWithTags, displayPath(vendorConfigFileName), tags)
310310
}
311311
}
312312

@@ -316,12 +316,12 @@ func validateTagsAndComponents(
316316

317317
if duplicates := lo.FindDuplicates(components); len(duplicates) > 0 {
318318
return fmt.Errorf("%w %v in the vendor config file '%s' and the imports",
319-
ErrDuplicateComponents, duplicates, vendorConfigFileName)
319+
ErrDuplicateComponents, duplicates, displayPath(vendorConfigFileName))
320320
}
321321

322322
if component != "" && !slices.Contains(components, component) {
323323
return fmt.Errorf("%w component '%s', file '%s'",
324-
ErrComponentNotDefined, component, vendorConfigFileName)
324+
ErrComponentNotDefined, component, displayPath(vendorConfigFileName))
325325
}
326326

327327
return nil
@@ -592,8 +592,8 @@ func processVendorImports(
592592
return nil, nil, fmt.Errorf(
593593
"%w '%s' in the vendor config file '%s'. It was already imported in the import chain",
594594
ErrDuplicateImport,
595-
imp,
596-
vendorConfigFile,
595+
displayPath(imp),
596+
displayPath(vendorConfigFile),
597597
)
598598
}
599599

@@ -605,11 +605,11 @@ func processVendorImports(
605605
}
606606

607607
if slices.Contains(vendorConfig.Spec.Imports, imp) {
608-
return nil, nil, fmt.Errorf("%w file '%s'", ErrVendorConfigSelfImport, imp)
608+
return nil, nil, fmt.Errorf("%w file '%s'", ErrVendorConfigSelfImport, displayPath(imp))
609609
}
610610

611611
if len(vendorConfig.Spec.Sources) == 0 && len(vendorConfig.Spec.Imports) == 0 {
612-
return nil, nil, fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, imp)
612+
return nil, nil, fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, displayPath(imp))
613613
}
614614

615615
mergedSources, allImports, err = processVendorImports(atmosConfig, imp, vendorConfig.Spec.Imports, mergedSources, allImports)
@@ -641,10 +641,10 @@ func validateSourceFields(s *schema.AtmosVendorSource, vendorConfigFileName stri
641641
s.File = vendorConfigFileName
642642
}
643643
if s.Source == "" {
644-
return fmt.Errorf("%w `%s`", ErrSourceMissing, s.File)
644+
return fmt.Errorf("%w `%s`", ErrSourceMissing, displayPath(s.File))
645645
}
646646
if len(s.Targets) == 0 {
647-
return fmt.Errorf("%w for source '%s' in file '%s'", ErrTargetsMissing, s.Source, s.File)
647+
return fmt.Errorf("%w for source '%s' in file '%s'", ErrTargetsMissing, s.Source, displayPath(s.File))
648648
}
649649
if err := version.ValidateVersionRangeConstraints(s.Version, s.Constraints); err != nil {
650650
return err

0 commit comments

Comments
 (0)