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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A check declares a set of languages rather than one, and `Consistency.SpellingDialect` now reads Markdown as well as TypeScript (CD-316). The check shipped against the codebase but not against the corpus that motivated it: a docs tree split between "analyz*" and "analys*", which it could not reach because `Check::language()` returned a single `Language` and the engine dispatched on equality. `Check::languages()` returns `&'static [Language]`, defaulting to `&[Language::TypeScript]`, so every check that does not care compiles unchanged; the four that do — three Rust checks and one HTML — name their own. Registering a second check id for Markdown was the cheaper option and was rejected: two entries in the catalogue for one convention is a worse surface than the gap. In Markdown the whole document is prose, so what gets excluded is the code a page quotes — fenced blocks, inline code spans, link destinations and YAML frontmatter — but not indented blocks, since four spaces is also a nested list continuation. Markdown discovery remains opt-in behind `[engine] extra_extensions`, so no project sees new findings without asking.

### Fixed
- `Design.LayerViolation`'s catalogue page no longer recommends `.cofferdamignore` for excluding tests. That is the exact configuration that makes `Design.OrphanExport` claim a symbol is "never imported in the project" when its only importers are the ignored tests — `.cofferdamignore` prunes files before discovery, so they contribute no import edges either (CD-325). The page now points at an `[[overrides]]` block with `disabled = true`, which turns off the one check without removing the files from the graph. The underlying behaviour is unchanged and tracked separately.
- `Refactor.PurityHeuristic` can be switched on (CD-324). Its one option is called `enabled`, and the config loader stripped that key out of every check's option bag before validation as a forward-compatible placeholder — so the check's own catalogue page told the user to write `enabled = true`, and the check never saw it. A registered check no user could reach is worse than an unrecognised key, so `enabled` now flows through to the option bag like any other option, and is dropped again only for the checks that do not declare it. Configs carrying a stray `enabled` therefore keep loading rather than failing on upgrade, which is why the fix is silent rather than a warning: making the inert case loud needs a warning sink `options_for_raw` does not have, and is not worth a breaking change to reach.
- `scripts/version.mjs set X.Y.Z --regen` could stamp the previous version into `docs/public/checks.json` and `docs/public/llms.txt` (CD-317). A stale cargo fingerprint after a `Cargo.toml` revert-then-rebump left `cargo build --workspace` reporting `Finished` without recompiling `cofferdam-cli`, so the subsequent `gen-docs` ran the old binary and wrote the old version, and `node scripts/version.mjs check` then failed on locations that regen was meant to fix. `--regen` now runs `cargo clean -p cofferdam-cli` before the build to force that crate's recompile, checks the built binary's own `--version` against the target before trusting it to run `gen-docs`, and re-reads `checks.json`/`llms.txt` afterwards to confirm they landed on the target version — failing loudly and naming both versions if not.
- `--hide-baselined` now applies to every output format, not text alone (CD-315). The flag filtered findings inside the text formatter, so `--format=json`, `compact` and `sarif` received the untouched list and printed baselined findings anyway, marked `"baselined": true`. Nothing in the help text said so, and a CI pipeline reading JSON was quietly counting pre-existing findings as new work. The filter now runs once in the CLI, ahead of the per-format branch, so all four formats render the same list. Summary counts are deliberately left whole — `total`, `new` and `baselined` still describe the full run, matching what the text formatter has always done — so the gate count stays visible when the entries are hidden.

Expand Down
8 changes: 6 additions & 2 deletions crates/cofferdam-checks/docs/Design.LayerViolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ whose resolved target is in layer **B**, where **B** is not in the

* No "test layer" sugar yet. If you want tests to import from anywhere,
add a `test` layer to your config and put it on every other layer's
`allow` list (or, simpler: exclude tests from the analysis via
`.cofferdamignore` since tests typically don't need their own layer).
`allow` list (or, simpler, turn this one check off over the test glob
with an `[[overrides]]` block carrying `disabled = true`). Do not reach
for `.cofferdamignore` here: it prunes the files before discovery, so
they contribute no import edges either, and `Design.OrphanExport` then
reports every symbol only the tests import as never imported at all
(CD-325).
* Re-exports through barrel files attribute the violation to the
re-exporter, not the eventual consumer. If you want barrels to be
transparent, file an issue — the graph already records re-export
Expand Down
27 changes: 12 additions & 15 deletions crates/cofferdam-engine/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ use super::{ConfigError, ContextSuppressRule, OverrideBlock, OverrideCheck, Proj
/// Filename loaders look for during walk-up discovery.
pub const FILE_NAME: &str = "cofferdam.toml";

/// Meta-keys that may appear inside `[checks."X.Y"]` blocks but aren't
/// per-check options. `severity` (cd-t1a) is now first-class — extracted
/// into `ProjectConfig::severity_overrides` rather than passed through
/// to `validate_options`. `enabled` is still a forward-compatible
/// placeholder (no behaviour wired yet).
const META_KEYS: &[&str] = &["severity", "enabled"];

/// TOML document layout. Top-level sections grow additively here.
#[derive(Debug, Deserialize, Default)]
pub struct TomlDoc {
Expand Down Expand Up @@ -158,9 +151,18 @@ pub fn parse(path: &Path, raw: &str) -> Result<ProjectConfig, ConfigError> {

let mut options: BTreeMap<String, RawOptionValue> = BTreeMap::new();
for (key, val) in table {
// Meta-keys are extracted into their own slots, not passed
// through to per-check option validation (which would
// reject them as UnknownKey).
// `severity` (cd-t1a) is a meta-key, extracted into its own
// slot rather than passed through to per-check option
// validation (which would reject it as UnknownKey).
//
// `enabled` used to get the same treatment, as a
// forward-compatible placeholder. That silently discarded
// the only option `Refactor.PurityHeuristic` has — its
// catalogue page tells the user to write `enabled = true` —
// leaving a registered check nobody could turn on (CD-324).
// It now reaches the option bag; `options_for_raw` drops it
// again for the checks that do not declare it, so configs
// written against the old placeholder keep loading.
if key == "severity" {
let s = match &val {
toml::Value::String(s) => s.clone(),
Expand All @@ -180,11 +182,6 @@ pub fn parse(path: &Path, raw: &str) -> Result<ProjectConfig, ConfigError> {
severity_overrides.insert(check_id.clone(), sev);
continue;
}
if META_KEYS.contains(&key.as_str()) {
// `enabled` (and any future placeholders) — silently
// accepted, no behaviour wired today.
continue;
}
let raw = toml_to_raw(&val).ok_or_else(|| ConfigError::UnsupportedValue {
path: path.to_path_buf(),
check_id: check_id.clone(),
Expand Down
61 changes: 54 additions & 7 deletions crates/cofferdam-engine/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
//! [checks."Readability.MaxLineLength"]
//! limit = 120
//! severity = "warning" # phase-3 (cd-t1a) — accepted, not yet enforced
//! enabled = true # phase-3 — accepted, not yet enforced
//! enabled = true # an option like any other; only checks that
//! # declare it see it (CD-324)
//!
//! [checks."Readability.MaxFunctionLength"]
//! limit = 50
Expand Down Expand Up @@ -572,19 +573,65 @@ enabled = true
.checks
.get("Readability.MaxLineLength")
.expect("present");
// `limit` flows through to the per-check option bag; `severity`
// goes to `severity_overrides`; `enabled` is silently accepted
// (no behaviour wired today).
assert_eq!(bag.len(), 1);
// `limit` and `enabled` flow through to the per-check option
// bag; `severity` goes to `severity_overrides`.
assert_eq!(bag.len(), 2);
assert!(bag.contains_key("limit"));
assert!(bag.contains_key("enabled"));
assert!(!bag.contains_key("severity"));
assert!(!bag.contains_key("enabled"));
assert_eq!(
cfg.severity_overrides.get("Readability.MaxLineLength"),
Some(&Severity::High)
);
}

/// CD-324: `enabled` used to be stripped by the loader for every
/// check, which made `Refactor.PurityHeuristic` — whose only option
/// is called `enabled` — impossible to switch on. It now reaches the
/// option bag when the check declares it.
#[test]
fn enabled_reaches_a_check_that_declares_it() {
let raw = r#"
[checks."Refactor.PurityHeuristic"]
enabled = true
"#;
let cfg = loader::parse(Path::new("test.toml"), raw).expect("parse");
let schema = &[cofferdam_core::OptionSpec {
name: "enabled",
kind: cofferdam_core::OptionKind::Bool,
default: cofferdam_core::OptionDefault::Bool(false),
doc: "opt in",
}];
let opts = options_for(
&cfg,
Path::new("test.toml"),
"Refactor.PurityHeuristic",
schema,
)
.expect("validates");
assert_eq!(opts.get_bool("enabled"), Some(true));
}

/// ...and is still tolerated (dropped, not an error) for the checks
/// that do not, so configs written against the old placeholder keep
/// loading.
#[test]
fn enabled_is_dropped_for_a_check_that_does_not_declare_it() {
let raw = r#"
[checks."Readability.MaxLineLength"]
enabled = false
"#;
let cfg = loader::parse(Path::new("test.toml"), raw).expect("parse");
let opts = options_for(
&cfg,
Path::new("test.toml"),
"Readability.MaxLineLength",
&[],
)
.expect("must not reject a legacy `enabled` key");
assert_eq!(opts.get_bool("enabled"), None);
}

#[test]
fn parse_context_suppress_block() {
let raw = r#"
Expand Down Expand Up @@ -991,7 +1038,7 @@ ui = ["domain"]
//
// The parse() step strips `limit` into the raw bag before
// reaching options_for, but `plugins` (an array) would be kept
// as-is if it weren't in META_KEYS — so we test options_for
// as-is — so we test options_for
// directly with `plugins` in the raw bag.
let err = options_for_with_raw_key(
"Readability.MaxLineLength",
Expand Down
18 changes: 18 additions & 0 deletions crates/cofferdam-engine/src/config/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ pub fn options_for_raw(
schema: &[cofferdam_core::OptionSpec],
raw: &BTreeMap<String, RawOptionValue>,
) -> Result<CheckOptions, ConfigError> {
// `enabled` was historically stripped by the config loader for every
// check, as a forward-compatible placeholder. That made
// `Refactor.PurityHeuristic` — whose sole option is called `enabled`
// — impossible to turn on (CD-324). The loader now passes it
// through; drop it here only for the checks that do not declare it,
// so configs written against the old placeholder keep loading.
let stripped;
let raw = if raw.contains_key("enabled") && !schema.iter().any(|s| s.name == "enabled") {
stripped = raw
.iter()
.filter(|(k, _)| k.as_str() != "enabled")
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>();
&stripped
} else {
raw
};

validate_options(check_id, schema, raw).map_err(|source| {
// When `validate_options` rejects a key that looks like a
// well-known top-level cofferdam.toml key (plugins, extends,
Expand Down
2 changes: 1 addition & 1 deletion crates/cofferdam-engine/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const CHECK_ENTRY_KEYS: &[KeySpec] = &[
key: "enabled",
type_name: "bool",
default: None,
doc: "Accepted but not yet wired to anything. To disable a check over a path glob, use `[[overrides]]` with `disabled = true`.",
doc: "Passed to the check as an option. Only `Refactor.PurityHeuristic` declares it (as its opt-in switch); for every other check it is accepted and ignored. To turn a check off over a path glob, use `[[overrides]]` with `disabled = true`.",
},
];

Expand Down
8 changes: 6 additions & 2 deletions docs/checks/Design.LayerViolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,12 @@ whose resolved target is in layer **B**, where **B** is not in the

* No "test layer" sugar yet. If you want tests to import from anywhere,
add a `test` layer to your config and put it on every other layer's
`allow` list (or, simpler: exclude tests from the analysis via
`.cofferdamignore` since tests typically don't need their own layer).
`allow` list (or, simpler, turn this one check off over the test glob
with an `[[overrides]]` block carrying `disabled = true`). Do not reach
for `.cofferdamignore` here: it prunes the files before discovery, so
they contribute no import edges either, and `Design.OrphanExport` then
reports every symbol only the tests import as never imported at all
(CD-325).
* Re-exports through barrel files attribute the violation to the
re-exporter, not the eventual consumer. If you want barrels to be
transparent, file an issue — the graph already records re-export
Expand Down
2 changes: 1 addition & 1 deletion docs/public/checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@
"base_priority": 9,
"default_severity": "High",
"explanation": "An import crosses a declared architectural layer in a direction not permitted by [layers].allow.",
"body": "---\nid: Design.LayerViolation\ncategory: Design\ndefault_severity: High\nbase_priority: 9\n---\n\n# Design.LayerViolation\n\nEnforces architectural layering rules declared in\n`cofferdam.invariants.toml` (see [the invariants reference](../invariants.md)\nfor the canonical config location and field reference). Each file is\nmapped to a layer via gitignore-style globs, and every import edge is\nchecked against an explicit allow-list of cross-layer dependencies.\n\n## Why\n\nLayered architectures (hexagonal, onion, clean, n-tier) are easier to\nreason about and refactor when the dependency direction is enforced\nmechanically. Without enforcement, an `app/` file imports a `domain/`\nhelper which imports back into `app/` \"just this once,\" and within a\nquarter the layers are sand. Cofferdam can hold the line at PR time.\n\n## Configuration\n\nIn `cofferdam.invariants.toml` (the legacy single-file `cofferdam.toml`\nform is deprecated — see `docs/invariants.md`):\n\n```toml\n[layers]\ninfra = [\"src/infra/**\"]\ndomain = [\"src/domain/**\", \"src/shared/**\"]\napp = [\"src/app/**\"]\n\n[layers.allow]\ndomain = [\"infra\"] # domain may import from infra\napp = [\"domain\", \"infra\"] # app may import from both\n# infra omitted → infra is isolated, must not import from any other layer\n```\n\nGlob patterns follow gitignore syntax. They're matched against each\nfile's path relative to the project root (where\n`cofferdam.invariants.toml` lives). When multiple layers match a file,\nthe one with the most-specific glob (longest non-glob prefix in its\ninclude patterns) wins; alphabetical layer name breaks true ties. Use\n`!pattern` within a layer's glob list to carve out subtrees explicitly:\n\n```toml\n[layers]\nui = [\"components/ui/**\"]\ncomponents = [\"components/**\", \"!components/ui/**\"]\n```\n\n## What gets flagged\n\nEvery static or dynamic import whose source file is in layer **A** and\nwhose resolved target is in layer **B**, where **B** is not in the\n`allow` list for **A**.\n\n## What's not flagged\n\n* Files outside any declared layer — the project hasn't said how to\n think about them yet, so the check stays silent.\n* Same-layer imports — always permitted.\n* Type-only imports (`import type { … }`) — they're erased at compile\n time, no runtime layering implication.\n* External imports (anything in `node_modules`) — out of scope.\n\n## Limitations\n\n* No \"test layer\" sugar yet. If you want tests to import from anywhere,\n add a `test` layer to your config and put it on every other layer's\n `allow` list (or, simpler: exclude tests from the analysis via\n `.cofferdamignore` since tests typically don't need their own layer).\n* Re-exports through barrel files attribute the violation to the\n re-exporter, not the eventual consumer. If you want barrels to be\n transparent, file an issue — the graph already records re-export\n source paths so attribution can chain.\n",
"body": "---\nid: Design.LayerViolation\ncategory: Design\ndefault_severity: High\nbase_priority: 9\n---\n\n# Design.LayerViolation\n\nEnforces architectural layering rules declared in\n`cofferdam.invariants.toml` (see [the invariants reference](../invariants.md)\nfor the canonical config location and field reference). Each file is\nmapped to a layer via gitignore-style globs, and every import edge is\nchecked against an explicit allow-list of cross-layer dependencies.\n\n## Why\n\nLayered architectures (hexagonal, onion, clean, n-tier) are easier to\nreason about and refactor when the dependency direction is enforced\nmechanically. Without enforcement, an `app/` file imports a `domain/`\nhelper which imports back into `app/` \"just this once,\" and within a\nquarter the layers are sand. Cofferdam can hold the line at PR time.\n\n## Configuration\n\nIn `cofferdam.invariants.toml` (the legacy single-file `cofferdam.toml`\nform is deprecated — see `docs/invariants.md`):\n\n```toml\n[layers]\ninfra = [\"src/infra/**\"]\ndomain = [\"src/domain/**\", \"src/shared/**\"]\napp = [\"src/app/**\"]\n\n[layers.allow]\ndomain = [\"infra\"] # domain may import from infra\napp = [\"domain\", \"infra\"] # app may import from both\n# infra omitted → infra is isolated, must not import from any other layer\n```\n\nGlob patterns follow gitignore syntax. They're matched against each\nfile's path relative to the project root (where\n`cofferdam.invariants.toml` lives). When multiple layers match a file,\nthe one with the most-specific glob (longest non-glob prefix in its\ninclude patterns) wins; alphabetical layer name breaks true ties. Use\n`!pattern` within a layer's glob list to carve out subtrees explicitly:\n\n```toml\n[layers]\nui = [\"components/ui/**\"]\ncomponents = [\"components/**\", \"!components/ui/**\"]\n```\n\n## What gets flagged\n\nEvery static or dynamic import whose source file is in layer **A** and\nwhose resolved target is in layer **B**, where **B** is not in the\n`allow` list for **A**.\n\n## What's not flagged\n\n* Files outside any declared layer — the project hasn't said how to\n think about them yet, so the check stays silent.\n* Same-layer imports — always permitted.\n* Type-only imports (`import type { … }`) — they're erased at compile\n time, no runtime layering implication.\n* External imports (anything in `node_modules`) — out of scope.\n\n## Limitations\n\n* No \"test layer\" sugar yet. If you want tests to import from anywhere,\n add a `test` layer to your config and put it on every other layer's\n `allow` list (or, simpler, turn this one check off over the test glob\n with an `[[overrides]]` block carrying `disabled = true`). Do not reach\n for `.cofferdamignore` here: it prunes the files before discovery, so\n they contribute no import edges either, and `Design.OrphanExport` then\n reports every symbol only the tests import as never imported at all\n (CD-325).\n* Re-exports through barrel files attribute the violation to the\n re-exporter, not the eventual consumer. If you want barrels to be\n transparent, file an issue — the graph already records re-export\n source paths so attribution can chain.\n",
"requires_types": false,
"consistency": false,
"autofix": false,
Expand Down
Loading
Loading