Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"vscode": {
"extensions": [
// renovate: datasource=github-tags depName=anthropics/claude-code
"anthropic.claude-code@2.1.206",
"anthropic.claude-code@2.1.219",
// renovate: datasource=github-tags depName=editorconfig/editorconfig-vscode
"EditorConfig.EditorConfig@0.18.2",
// renovate: datasource=github-tags depName=golang/vscode-go
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ Select a different output format to change this:
::warning file=.devcontainer/devcontainer.json,line=4,col=12,title=no-image-latest::image "ubuntu:latest" uses the "latest" tag; pin a specific version
```

Every format reports paths relative to the directory `decolint` runs in,
whichever way the linted directory was named on the command line. A file
outside that directory is reported with its absolute path.

### Exit codes

- `0` — no `error`-severity findings (there may still be `warn`
Expand Down
122 changes: 78 additions & 44 deletions cmd/decolint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,21 @@ func runLint(ctx context.Context, stdout, stderr io.Writer, opts Options, cfg Co
var allIssues []linter.Issue
var worstSeverity linter.Severity
var lintErr error
for _, path := range opts.Paths {
issues, err := lintPath(ctx, l, subst, merge, path)
// A directory named more than once is linted once, so its findings are not reported twice.
seen := make(map[absPath]struct{}, len(opts.Paths))
for _, target := range opts.Paths {
abs, err := filepath.Abs(target)
if err != nil {
lintErr = errors.Join(lintErr, fmt.Errorf("resolve directory %s: %w", target, err))
continue
}
dir := absPath(abs)
if _, ok := seen[dir]; ok {
continue
}
seen[dir] = struct{}{}

issues, err := lintPath(ctx, l, subst, merge, dir)
for _, issue := range issues {
if issue.Severity > worstSeverity {
worstSeverity = issue.Severity
Expand Down Expand Up @@ -297,50 +310,61 @@ type mergeFn func(ctx context.Context, f discovery.ConfigFile, doc *linter.Docum
// lintFile.
type substituteFn func(workspaceFolder string, doc *linter.Document)

// lintPath lints the devcontainer directory at path. The directory is opened as an os.Root, so
// every file the lint reads is confined to it. It is an error if path is not a directory.
func lintPath(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, path string) ([]linter.Issue, error) {
root, err := os.OpenRoot(path)
// absPath is the location of a file or directory, always absolute so that it means the same thing
// however the lint target was named on the command line. Printing it does not yield that absolute
// form; see [absPath.String].
type absPath string

// String renders p the way findings and error messages name it: relative to the working directory
// when it is inside it, absolute otherwise — a path reached by climbing out of the working directory
// reads no better than its own location.
func (p absPath) String() string {
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve directory %s: %w", path, err)
return string(p)
}
// The root is only read from, so a close error is inconsequential.
defer func() { _ = root.Close() }()
issues, err := lintDir(ctx, l, subst, merge, root)
rel, err := filepath.Rel(wd, string(p))
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return string(p)
}
return rel
}

// lintPath lints the devcontainer directory dir. It is opened as an os.Root, so every file the lint
// reads is confined to it; because dir is absolute, so is every path derived from it. It is an error
// if dir is not a directory.
func lintPath(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, dir absPath) ([]linter.Issue, error) {
root, err := os.OpenRoot(string(dir))
if err != nil {
return nil, fmt.Errorf("lint directory %s: %w", path, err)
// Pointing decolint straight at a devcontainer.json is a natural mistake, and "not a
// directory" alone does not say what to pass instead.
if info, statErr := os.Stat(string(dir)); statErr == nil && !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory; pass the directory that contains the devcontainer configuration", dir)
}
// os.OpenRoot's error already names the path.
return nil, fmt.Errorf("lint directory: %w", err)
}
return issues, nil
// The root is only read from, so a close error is inconsequential.
defer func() { _ = root.Close() }()
return lintDir(ctx, l, subst, merge, root)
}

// lintDir lints every configuration file in the devcontainer directory root is opened on. It is an
// error if the directory contains no configuration. Issue paths are the files' locations joined
// onto root's name.
// error if the directory contains no configuration.
func lintDir(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, root *os.Root) ([]linter.Issue, error) {
dir := root.Name()
dir := absPath(root.Name())
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("aborted %s: %w", dir, err)
}
// The linted directory is the workspace folder the real tooling would mount, even for a
// configuration discovered in a sub-root like .devcontainer. It is only needed for variable
// substitution, so it is resolved only when that runs.
var workspaceFolder string
if subst != nil {
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve workspace folder %s: %w", dir, err)
}
workspaceFolder = abs
}
var issues []linter.Issue
var errs []error
found := false
err := discovery.VisitConfigs(root, func(f discovery.ConfigFile) error {
found = true
if err := ctx.Err(); err != nil {
return fmt.Errorf("aborted %s: %w", filepath.Join(f.Root.Name(), f.Path), err)
return fmt.Errorf("aborted %s: %w", configPath(f), err)
}
fileIssues, err := lintFile(ctx, l, subst, merge, f, workspaceFolder)
fileIssues, err := lintFile(ctx, l, subst, merge, f, dir)
if err != nil {
// A broken file must not stop the remaining files from being linted, so record the
// error and keep visiting.
Expand All @@ -359,35 +383,45 @@ func lintDir(ctx context.Context, l *linter.Linter, subst substituteFn, merge me
return issues, errors.Join(errs...)
}

// lintFile reads and lints the single configuration file f, reporting issues under
// filepath.Join(f.Root.Name(), f.Path). The file is read through f.Root, so its resolution cannot
// escape that boundary. subst, if non-nil, resolves variables in dev container configurations
// before merge and rules run, so both see resolved values. merge, if non-nil, runs on dev container
// configurations before rules and is skipped when no rule applies to f's type, so a file with no
// active rules does no Feature fetches.
func lintFile(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, f discovery.ConfigFile, workspaceFolder string) ([]linter.Issue, error) {
display := filepath.Join(f.Root.Name(), f.Path)
// configPath returns the location of the configuration file f. It is absolute because f.Root is the
// lint root or a sub-root of it, both opened on an absolute path (see lintPath).
func configPath(f discovery.ConfigFile) absPath {
return absPath(filepath.Join(f.Root.Name(), f.Path))
}

// lintFile reads and lints the single configuration file f, found in the devcontainer directory dir.
// The file is read through f.Root, so its resolution cannot escape that boundary. subst, if non-nil,
// resolves variables in dev container configurations before merge and rules run, so both see
// resolved values. merge, if non-nil, runs on dev container configurations before rules and is
// skipped when no rule applies to f's type, so a file with no active rules does no Feature fetches.
func lintFile(ctx context.Context, l *linter.Linter, subst substituteFn, merge mergeFn, f discovery.ConfigFile, dir absPath) ([]linter.Issue, error) {
location := configPath(f)
src, err := f.Root.ReadFile(f.Path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", display, err)
return nil, fmt.Errorf("read config %s: %w", location, err)
}
doc, err := linter.ParseDocument(src)
if err != nil {
return nil, fmt.Errorf("%s: %w", display, err)
return nil, fmt.Errorf("%s: %w", location, err)
}
if subst != nil && f.Type == linter.Devcontainer {
subst(workspaceFolder, doc)
// The linted directory is the workspace folder the real tooling would mount, even for a
// configuration discovered in a sub-root like .devcontainer.
subst(string(dir), doc)
}
if merge != nil && f.Type == linter.Devcontainer && l.HasRules(f.Type) {
if err := merge(ctx, f, doc); err != nil {
return nil, fmt.Errorf("merge features %s: %w", display, err)
return nil, fmt.Errorf("merge features %s: %w", location, err)
}
}
// Give rules read access to the config file's directory, confined to the discovery root so
// resolution cannot escape it (see Context.Dir).
dir, err := fs.Sub(f.Root.FS(), path.Dir(filepath.ToSlash(f.Path)))
// resolution cannot escape it (see linter.Dir).
sub, err := fs.Sub(f.Root.FS(), path.Dir(filepath.ToSlash(f.Path)))
if err != nil {
return nil, fmt.Errorf("open config dir %s: %w", display, err)
return nil, fmt.Errorf("open config dir %s: %w", location, err)
}
return l.LintDocument(display, f.Type, doc, dir), nil
// The reported path can be relative to the containing directory itself, naming no directory at
// all, so the name comes from the absolute location.
name := filepath.Base(filepath.Dir(string(location)))
return l.LintDocument(location.String(), f.Type, doc, linter.Dir{FS: sub, Name: name}), nil
}
Loading
Loading