Project-level .aidd-lint.yml replaces the shipped config instead of merging — a minimal one silently lints nothing #167

Open
opened 2026-07-27 16:45:41 +00:00 by jared · 1 comment
Owner

Problem

AiddLint::Config.load_for walks up from the target file, and the nearest .aidd-lint.yml it finds wholly replaces the plugin's shipped defaults. There is no deep merge — Config#initialize just wraps the one loaded hash.

The failure is silent and total. Scope.in_scope? (lib/aidd_lint/scope.rb:10) does:

return false if includes.empty?

A project config without a paths.include key yields includes == [], so every file is out of scope and the linter silently lints nothing. No error, no warning — it just stops finding anything, which reads exactly like a clean repo.

Reproduction

Project root containing only:

tier2:
  cross_file_authority: true

Result:

in scope, project cfg (tier2 only): false
in scope, shipped cfg:              true

(subject skills/foo/SKILL.md, which the shipped paths.include matches via **/SKILL.md)

Everything else silently reverts to hardcoded fallbacks rather than shipped values:

key shipped with minimal project cfg
paths.include 6 globs nilnothing in scope
gate.min_changed_words 20 nil → 20 via .fetch fallback (safe)
tier2.model_tier haiku nilhaiku via `
tier2.max_findings 20 nil → 20 via `
cops.StepBulk.max_lines 4 missing → cop's own default

So the tuned cop thresholds and the gate degrade quietly to in-code defaults, and paths: fails closed to a no-op linter.

Why this surfaced now

#164 added tier2.cross_file_authority, whose documented enable mechanism is exactly this: drop an .aidd-lint.yml at the linted project's root. That is the first setting a user is likely to enable from a project that has no config file yet, which walks them straight into the trap — they turn on the deep cross-file scan and the linter goes quiet, which looks like the scan finding nothing rather than the scan never running.

What I verified

  • Read lib/aidd_lint/config.rbload_for/find_config/initialize: single-hash wrap, no merge anywhere.
  • Ran the reproduction above against Scope.in_scope? and Config#top_level_setting/#setting with a real temp project root.

What I did NOT verify

  • Whether replace-not-merge is a deliberate design choice (a project asserting full control of its own lint config is a defensible stance). If so the bug is the silent part, not the semantics.
  • Real-world blast radius — I did not check whether any repo in the wild currently ships a partial .aidd-lint.yml.

Suggested resolution

Pick one, maintainer's call:

  1. Deep-merge project config over shipped defaults, so a partial file only overrides what it names. Most forgiving; changes existing semantics.
  2. Keep replace, fail loudly — warn (or abort) when a loaded project config has no paths.include, rather than silently scoping to nothing.
  3. Keep replace, document it — require project configs to be complete, and say so where the config is documented.

Option 2 is the smallest change that removes the silent-failure mode, and is compatible with either of the others later.

Provenance

Found while implementing #164, verifying that ticket's documented enable path end to end. Pre-existing behavior, not introduced by #164; deliberately not changed there, since altering config merge semantics under a charter-amendment ticket would have been out of scope.

### Problem `AiddLint::Config.load_for` walks up from the target file, and the nearest `.aidd-lint.yml` it finds **wholly replaces** the plugin's shipped defaults. There is no deep merge — `Config#initialize` just wraps the one loaded hash. The failure is silent and total. `Scope.in_scope?` (`lib/aidd_lint/scope.rb:10`) does: ```ruby return false if includes.empty? ``` A project config without a `paths.include` key yields `includes == []`, so **every file is out of scope and the linter silently lints nothing**. No error, no warning — it just stops finding anything, which reads exactly like a clean repo. ### Reproduction Project root containing only: ```yaml tier2: cross_file_authority: true ``` Result: ``` in scope, project cfg (tier2 only): false in scope, shipped cfg: true ``` (subject `skills/foo/SKILL.md`, which the shipped `paths.include` matches via `**/SKILL.md`) Everything else silently reverts to hardcoded fallbacks rather than shipped values: | key | shipped | with minimal project cfg | |---|---|---| | `paths.include` | 6 globs | `nil` → **nothing in scope** | | `gate.min_changed_words` | 20 | `nil` → 20 via `.fetch` fallback (safe) | | `tier2.model_tier` | `haiku` | `nil` → `haiku` via `||` fallback (safe) | | `tier2.max_findings` | 20 | `nil` → 20 via `||` fallback (safe) | | `cops.StepBulk.max_lines` | 4 | missing → cop's own default | So the tuned cop thresholds and the gate degrade quietly to in-code defaults, and `paths:` fails closed to a no-op linter. ### Why this surfaced now #164 added `tier2.cross_file_authority`, whose documented enable mechanism is exactly this: drop an `.aidd-lint.yml` at the linted project's root. That is the first setting a user is likely to enable **from a project that has no config file yet**, which walks them straight into the trap — they turn on the deep cross-file scan and the linter goes quiet, which looks like the scan finding nothing rather than the scan never running. ### What I verified - Read `lib/aidd_lint/config.rb` — `load_for`/`find_config`/`initialize`: single-hash wrap, no merge anywhere. - Ran the reproduction above against `Scope.in_scope?` and `Config#top_level_setting`/`#setting` with a real temp project root. ### What I did NOT verify - Whether replace-not-merge is a deliberate design choice (a project asserting full control of its own lint config is a defensible stance). If so the bug is the **silent** part, not the semantics. - Real-world blast radius — I did not check whether any repo in the wild currently ships a partial `.aidd-lint.yml`. ### Suggested resolution Pick one, maintainer's call: 1. **Deep-merge** project config over shipped defaults, so a partial file only overrides what it names. Most forgiving; changes existing semantics. 2. **Keep replace, fail loudly** — warn (or abort) when a loaded project config has no `paths.include`, rather than silently scoping to nothing. 3. **Keep replace, document it** — require project configs to be complete, and say so where the config is documented. Option 2 is the smallest change that removes the silent-failure mode, and is compatible with either of the others later. ### Provenance Found while implementing #164, verifying that ticket's documented enable path end to end. Pre-existing behavior, not introduced by #164; deliberately not changed there, since altering config merge semantics under a charter-amendment ticket would have been out of scope.
Author
Owner

Frozen in the 2026-08-16 backlog reset — see #419 for the expiry procedure. Do not work unless a live run rediscovers this issue.

Frozen in the 2026-08-16 backlog reset — see #419 for the expiry procedure. Do not work unless a live run rediscovers this issue.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#167
No description provided.