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
13 changes: 13 additions & 0 deletions .release-notes/add-hierarchical-lint-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Add hierarchical configuration for pony-lint

pony-lint now supports `.pony-lint.json` files in subdirectories, not just at the project root. A subdirectory config overrides the root config for all files in that subtree, using the same JSON format.

For example, to turn off the `style/package-docstring` rule for everything under your `examples/` directory, add an `examples/.pony-lint.json`:

```json
{"rules": {"style/package-docstring": "off"}}
```

Precedence follows proximity — the nearest directory with a setting wins. Category entries (e.g., `"style": "off"`) override parent rule-specific entries in that category. Omitting a rule from a subdirectory config defers to the parent, not the default.

Malformed subdirectory configs produce a `lint/config-error` diagnostic and fall through to the parent config — the subtree is still linted, just with the parent's rules.
124 changes: 92 additions & 32 deletions tools/pony-lint/config.pony
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,54 @@ class val LintConfig
_cli_disabled = recover val Set[String] end
_file_rules = recover val Map[String, RuleStatus] end

new val merge(parent: LintConfig, child_rules: Map[String, RuleStatus] val)
=>
"""
Create a config that layers child overrides on top of a parent config.
CLI-disabled rules carry forward from the parent unchanged.

Category-cleaning: when the child has a category entry (key with no `/`),
all parent rule-specific entries in that category are removed before
layering. This ensures a child's `"style": "off"` overrides a parent's
`"style/line-length": "on"`.

A child config with both `"style": "off"` and `"style/line-length": "on"`
results in `style/line-length` being on and all other style rules off,
because `rule_status()` checks rule-specific before category.

Rule-specific child entries do NOT remove parent category entries.
"""
_cli_disabled = parent._cli_disabled
_file_rules =
recover val
let merged = Map[String, RuleStatus]
// Copy parent entries
for (k, v) in parent._file_rules.pairs() do
merged(k) = v
end
// Category cleaning: for each category key in child,
// remove parent rule-specific entries in that category
for child_key in child_rules.keys() do
if not child_key.contains("/") then
let prefix: String val = child_key + "/"
let to_remove = Array[String val]
for k in merged.keys() do
if k.at(prefix) then
to_remove.push(k)
end
end
for k in to_remove.values() do
try merged.remove(k)? end
end
end
end
// Layer child entries on top
for (k, v) in child_rules.pairs() do
merged(k) = v
end
merged
end

fun rule_status(
rule_id: String,
category: String,
Expand Down Expand Up @@ -68,16 +116,21 @@ class val LintConfig
primitive ConfigLoader
"""
Loads lint configuration from CLI arguments and/or a `.pony-lint.json` file.

`parse_file` is public so that subdirectory configs can be parsed during the
file-discovery walk (see `_FileCollector._load_config`).
"""
fun from_cli(
cli_disabled: Array[String] val,
config_path: (String | None),
file_auth: FileAuth)
: (LintConfig | ConfigError)
: ((LintConfig, String val) | ConfigError)
=>
"""
Build a `LintConfig` from CLI disable flags and an optional config file.
If `config_path` is None, auto-discovers the config file.
If `config_path` is None, auto-discovers the config file. Returns the
config and the hierarchy root directory (used by `ConfigResolver` to
anchor subdirectory config walks).
"""
let disabled =
recover val
Expand All @@ -87,64 +140,71 @@ primitive ConfigLoader
end
s
end
let file_rules =
match \exhaustive\ config_path
| let path: String =>
let fp = FilePath(file_auth, path)
if not fp.exists() then
return ConfigError("config file not found: " + path)
end
match \exhaustive\ _load_file(fp)
| let rules: Map[String, RuleStatus] val => rules
| let err: ConfigError => return err
end
| None =>
match \exhaustive\ _discover(file_auth)
| let fp: FilePath =>
match \exhaustive\ _load_file(fp)
| let rules: Map[String, RuleStatus] val => rules
| let err: ConfigError => return err
end
| None =>
recover val Map[String, RuleStatus] end
match \exhaustive\ config_path
| let path: String =>
let fp = FilePath(file_auth, path)
if not fp.exists() then
return ConfigError("config file not found: " + path)
end
match \exhaustive\ parse_file(fp)
| let rules: Map[String, RuleStatus] val =>
(LintConfig(disabled, rules), Path.dir(fp.path))
| let err: ConfigError => err
end
| None =>
match \exhaustive\ _discover(file_auth)
| (let fp: FilePath, let root_dir: String val) =>
match \exhaustive\ parse_file(fp)
| let rules: Map[String, RuleStatus] val =>
(LintConfig(disabled, rules), root_dir)
| let err: ConfigError => err
end
| let root_dir: String val =>
(LintConfig(disabled, recover val Map[String, RuleStatus] end),
root_dir)
end
LintConfig(disabled, file_rules)
end

fun _discover(file_auth: FileAuth): (FilePath | None) =>
fun _discover(file_auth: FileAuth)
: ((FilePath, String val) | String val)
=>
"""
Discover `.pony-lint.json` by checking CWD first, then walking up
to find `corral.json` and checking its directory.
to find `corral.json` and checking its directory. Always returns a
hierarchy root directory — either the directory containing the config
file, the `corral.json` directory, or CWD.
"""
let cwd = Path.cwd()
// Check CWD first
let cwd_config = Path.join(cwd, ".pony-lint.json")
let cwd_fp = FilePath(file_auth, cwd_config)
if cwd_fp.exists() then
return cwd_fp
return (cwd_fp, cwd)
end
// Walk up looking for corral.json
var dir = cwd
while true do
let corral_path = Path.join(dir, "corral.json")
let corral_fp = FilePath(file_auth, corral_path)
if corral_fp.exists() then
let config_path = Path.join(dir, ".pony-lint.json")
let config_fp = FilePath(file_auth, config_path)
let config_path' = Path.join(dir, ".pony-lint.json")
let config_fp = FilePath(file_auth, config_path')
if config_fp.exists() then
return config_fp
return (config_fp, dir)
end
return None
return dir
end
let parent = Path.dir(dir)
if parent == dir then
break
end
dir = parent
end
None
cwd

fun _load_file(fp: FilePath): (Map[String, RuleStatus] val | ConfigError) =>
fun parse_file(fp: FilePath)
: (Map[String, RuleStatus] val | ConfigError)
=>
"""
Parse a `.pony-lint.json` file into rule status overrides.
"""
Expand Down
78 changes: 78 additions & 0 deletions tools/pony-lint/config_resolver.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use "collections"
use "files"

class ref ConfigResolver
"""
Resolves per-directory rule status by merging the config hierarchy.

Accumulates per-directory overrides during the file-discovery walk via
`add_directory()`, then resolves the effective config for any directory
via `config_for()`. The hierarchy root is the directory containing the
root config file (or CWD if no root config exists).

Invariant: omission in a directory config means "defer to parent,"
not "reset to default."
"""
let _root_config: LintConfig
let _dir_overrides: Map[String, Map[String, RuleStatus] val]
let _root_dir: String val
let _cache: Map[String, LintConfig val]

new ref create(root_config: LintConfig, root_dir: String val) =>
_root_config = root_config
_dir_overrides = Map[String, Map[String, RuleStatus] val]
_root_dir = root_dir
_cache = Map[String, LintConfig val]

fun ref add_directory(
dir: String val,
rules: Map[String, RuleStatus] val)
=>
"""
Register an override config for a directory. Takes pre-parsed config
data — the caller handles I/O and parsing.

Skips the hierarchy root directory (its config is already in the root
LintConfig) and skips directories already registered (idempotent).
This guard is centralized here rather than in each caller to prevent
double-loading the root's config, which would corrupt rule-specific
entries via category cleaning.
"""
if (dir == _root_dir) or _dir_overrides.contains(dir) then return end
_dir_overrides(dir) = rules

fun ref config_for(dir: String val): LintConfig val =>
"""
Return the effective LintConfig for a directory by merging the
hierarchy from root to the given directory. Results are cached
per directory path.
"""
try return _cache(dir)? end
// Walk up from dir to root, collecting ancestor directories
// that have overrides (in leaf-to-root order)
let ancestors = Array[String val]
var current = dir
while true do
if _dir_overrides.contains(current) then
ancestors.push(current)
end
if current == _root_dir then break end
let parent = Path.dir(current)
if parent == current then break end
current = parent
end
// Merge root-to-leaf: start with root config,
// then merge each ancestor's overrides in root-to-leaf order
var merged: LintConfig val = _root_config
// ancestors is leaf-to-root, so iterate in reverse
var i = ancestors.size()
while i > 0 do
i = i - 1
try
let ancestor_dir = ancestors(i)?
let overrides = _dir_overrides(ancestor_dir)?
merged = LintConfig.merge(merged, overrides)
end
end
_cache(dir) = merged
merged
Loading
Loading