Skip to content
Open
7 changes: 7 additions & 0 deletions .skillsaw.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@

version: "0.18.0"

# Rule-set profile: a curated bundle of severity/enablement overrides applied
# under your own 'rules:' entries. Available: default, claude-5
# NOTE: the explicit per-rule entries below count as your own overrides and win
# over the profile — to adopt one, also delete the rule entries you want the
# profile to manage.
# profile: claude-5

rules:

# Agent Plugins plugin.json and skills location must conform to 1.0.0
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ reference](https://skillsaw.org/rules/) for details.
skillsaw works locally, in CI, and inside coding-agent workflows. It provides
line-level findings, explanations for every rule, deterministic autofixes,
baselines for gradual adoption, GitHub and GitLab integration, and text, JSON,
SARIF, HTML, and Code Climate output. Rules are configurable, and projects can
add local rules or install rule plugins.
SARIF, HTML, and Code Climate output. Rules are configurable — one-line
[profiles](https://skillsaw.org/configuration/#profiles) select curated rule
sets like `claude-5`, tuned to Anthropic's Claude 5 context-engineering
guidance — and projects can add local rules or install rule plugins.

| Goal | Documentation |
| --- | --- |
Expand Down
70 changes: 70 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ automatically but may occasionally fail after a skillsaw upgrade.
`version` to your skillsaw version (`skillsaw --version`) and bump it
when you upgrade.

The one exception: an unversioned config that selects a curated
[profile](#profiles) follows the **installed** version instead of
falling back to 0.6.0 — new rules apply immediately after upgrades
until you pin `version`.

## Profiles

A profile is a named, curated bundle of rule overrides — severities,
enablement, and rule parameters — selected with one config line instead of a
hand-maintained `rules:` block:

```yaml
version: "0.19.0"
profile: claude-5
```

Profile overrides apply **between** the builtin defaults and your own
`rules:` entries, so precedence is:

```
builtin defaults < profile < your rules: overrides
```

Anything you set per rule wins over the profile, including an explicit
`enabled: auto` (which restores repo-type/format detection over the
profile's decision). Nested rule parameters (like `context-budget`'s
`limits`) merge per key, so overriding one entry keeps the profile's
others. A severity-only override on a rule the profile
disables does **not** re-enable it — re-enabling takes an explicit
`enabled` setting. `skillsaw explain <rule-id>` reports when a rule's
enablement or severity comes from the active profile.

!!! note "Profiles and generated configs"
A config generated by `skillsaw init` lists explicit `enabled` and
`severity` entries for every rule — and those count as *your*
overrides, shadowing most of what a profile would change. To adopt a
profile in a generated config, set `profile:` **and delete** the
per-rule entries you want the profile to manage.

Available profiles:

| Profile | Meaning |
|---------|---------|
| `default` | The builtin defaults, unchanged. This is what runs when the key is absent. |
| `claude-5` | Tuned to Anthropic's [Claude 5 context-engineering guidance](https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models). |

The `claude-5` profile makes these changes over the defaults:

| Rule | Change | Rationale from the guidance |
|------|--------|------------------------------|
| `content-repeated-directive` | severity → `error` | "Eliminate duplicate instructions" — state each directive once |
| `content-instruction-drift` | severity → `warning` | The same shift, across files: drifted near-duplicates cost budget twice |
| `content-tautological` | severity → `warning` | Remove guidance modern models exhibit by default |
| `content-section-length` | severity → `warning` | Progressive disclosure: split long content into many files |
| `context-budget` | tighter `limits` for `claude-md`/`agents-md`/`gemini-md` (3k warn / 8k error) and `skill` (2k warn / 5k error) | "Keep your CLAUDE.md lightweight" |
| `content-missing-stop-condition` | `enabled: true` | Open-ended loops with no stop condition burn autonomous sessions |
| `content-weak-language` | `enabled: false` | Judgment-delegating prose is the recommended style; flagging hedges fights it |

A profile's `enabled` decisions bypass the config [`version`
gate](#version-pinning) — profiles ship with the installed skillsaw, so
choosing one is an explicit opt-in to its rule set. Severity- and
parameter-only profile entries never change whether a rule runs; activation
still follows the rule's own default and the `version` gate.

For the same reason, a config that selects a curated profile but omits
`version` follows the **installed** skillsaw version rather than falling
back to `0.6.0`: new rules apply immediately after upgrades. Set `version`
Comment on lines +137 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile the blanket missing-version warning

This exception contradicts the prominent warning above that every config without a version is treated as 0.6.0. With profile: claude-5, LinterConfig.from_file() instead assigns the installed version, so readers may incorrectly believe an unversioned profile remains pinned across upgrades; qualify the earlier warning to exclude curated profiles or cross-reference this exception.

AGENTS.md reference: AGENTS.md:L27-L31

Useful? React with 👍 / 👎.

explicitly to pin the rule set instead. (`profile: default` keeps the
legacy fallback, identical to having no profile key.)

## Enabling Rules

Each rule's `enabled` key accepts three values:
Expand Down
10 changes: 9 additions & 1 deletion src/skillsaw/cli/_explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ def _run_explain(args):
)
print(f" {state} — {reason}")
if enabled:
print(f" severity: {effective_severity}")
# Attribute a profile-supplied severity so the effective value is
# traceable; a user rules: entry overrides the profile and drops
# the annotation.
severity_note = ""
if "severity" in config._profile_rules(args.rule_id) and "severity" not in config.rules.get(
args.rule_id, {}
):
severity_note = f" (set by profile '{config.profile}')"
print(f" severity: {effective_severity}{severity_note}")

if plugin_name is None:
# Plugin rules have no page on the skillsaw documentation site.
Expand Down
145 changes: 127 additions & 18 deletions src/skillsaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Dict, Any, Optional, List, Set, Tuple, TYPE_CHECKING
from dataclasses import dataclass, field
from skillsaw.paths import safe_resolve
from skillsaw.profiles import DEFAULT_PROFILE, PROFILES, available_profiles

if TYPE_CHECKING:
from .context import RepositoryContext
Expand Down Expand Up @@ -76,11 +77,17 @@ class LinterConfig:
# Excluded from equality so two configs loaded the same way still compare
# equal regardless of the advisory messages attached.
warnings: List[str] = field(default_factory=list, compare=False)
# Named rule-set profile (see skillsaw.profiles): a curated bundle of
# rule overrides applied under the user's own ``rules:`` entries.
# Declared after every pre-profile field so the dataclass keeps the
# positional constructor signature of earlier releases.
profile: str = DEFAULT_PROFILE

# Recognised top-level config keys; anything else triggers a load warning.
_KNOWN_KEYS = frozenset(
{
"version",
"profile",
"rules",
"custom-rules",
"exclude",
Expand Down Expand Up @@ -131,15 +138,41 @@ def from_file(cls, config_path: Path) -> "LinterConfig":
+ ". Known keys: "
+ ", ".join(sorted(cls._KNOWN_KEYS))
)
raw_profile = data.get("profile")
if raw_profile is None:
profile = DEFAULT_PROFILE
Comment on lines +141 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject an explicitly empty profile value

When the config contains profile: with no value, YAML parses it as null and this branch silently selects the default profile. That makes an incomplete or accidentally blank profile declaration run a different rule set without warning, even though every other non-string profile value is rejected as invalid and the error states that the value must be one of the named profiles. Distinguish an absent key from an explicitly null value and reject the latter.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping null-as-absent deliberately: it matches this codebase's convention for every other key — fail-on: (null) behaves like the default (pinned by test_null_fail_on_does_not_crash), version: (null) falls back with a warning, and null rule entries coerce to {}. Rejecting null only for profile would be the inconsistency. e418d17 pins the behavior with test_null_profile_behaves_like_default so it's a documented choice rather than an accident.


Generated by Claude Code

elif isinstance(raw_profile, str) and raw_profile in PROFILES:
profile = raw_profile
else:
raise ValueError(
f"'profile' must be one of {', '.join(available_profiles())}, "
f"got {raw_profile!r}"
)

raw_version = data.get("version")
default_version = _DEFAULT_VERSION
if raw_version is None:
# Covers both a missing key and an explicit ``version:`` (None) —
# both would otherwise version-gate as 0.0.0 and disable newer rules.
load_warnings.append(
f"config has no 'version' field; defaulting to {_DEFAULT_VERSION}, so rules "
"added in later versions are silently disabled. Set 'version' to your "
"skillsaw version to enable them."
)
if profile != DEFAULT_PROFILE:
# Selecting a curated profile is an opt-in to the current
# rule set, so an unpinned config follows the installed
# version: new rules apply immediately after upgrades
# instead of being gated behind a version bump.
from . import __version__

default_version = __version__
load_warnings.append(
f"config has no 'version' field; 'profile: {profile}' implies the "
f"installed version ({__version__}), so new rules apply immediately "
"after upgrades. Set 'version' to pin the rule set instead."
)
else:
load_warnings.append(
f"config has no 'version' field; defaulting to {_DEFAULT_VERSION}, so rules "
"added in later versions are silently disabled. Set 'version' to your "
"skillsaw version to enable them."
)

raw_rules = data.get("rules")
raw_custom_rules = data.get("custom-rules")
Expand Down Expand Up @@ -286,7 +319,8 @@ def from_file(cls, config_path: Path) -> "LinterConfig":
)

return cls(
version=_DEFAULT_VERSION if raw_version is None else str(raw_version),
version=default_version if raw_version is None else str(raw_version),
profile=profile,
rules=rules,
custom_rules=custom_rules,
exclude_patterns=exclude_patterns,
Expand Down Expand Up @@ -349,25 +383,58 @@ def effective_fail_level(self) -> str:
candidates.append("warning")
return max(candidates, key=_FAIL_ON_LEVELS.__getitem__)

def _profile_rules(self, rule_id: str) -> Dict[str, Any]:
"""Overrides the active profile contributes for *rule_id* ({} if none)."""
profile = PROFILES.get(self.profile)
if profile is None:
return {}
return profile.rules.get(rule_id, {})

def _serializable_profile(self) -> bool:
"""Whether ``profile`` is worth writing out: a registered,
non-default name. A programmatically-assigned unknown profile
contributes no overrides, so serializing it would produce a file
``from_file()`` rejects — the effective (default) behavior is
written instead.
"""
return bool(self.profile) and self.profile != DEFAULT_PROFILE and self.profile in PROFILES

@staticmethod
def _merge_layer(base: Dict[str, Any], overlay: Dict[str, Any]) -> None:
"""Merge *overlay* into *base* in place, recursing into mappings.

Nested dicts merge per key so a layer overriding one entry of a
parameter like ``limits`` keeps the lower layer's other entries;
every non-mapping value (including lists) replaces wholesale.
"""
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
LinterConfig._merge_layer(base[key], value)
else:
base[key] = copy.deepcopy(value)

def get_rule_config(self, rule_id: str) -> Dict[str, Any]:
"""
Get configuration for a specific rule, merging user overrides
on top of defaults so unmentioned fields keep their default values.
Get configuration for a specific rule, merging the active profile's
overrides and then user overrides on top of defaults, so unmentioned
fields — including entries of nested mappings — keep their default
(or profile-set) values.

Args:
rule_id: Rule identifier

Returns:
Rule configuration dict
"""
# Deep-copy the cached defaults so callers mutating the merged result
# (or its nested lists like ``recommended-fields``) cannot corrupt the
# shared cache.
defaults = copy.deepcopy(_default_rules().get(rule_id, {}))
# Deep-copy the cached defaults (and, in _merge_layer, every overlay
# value) so callers mutating the merged result — or its nested
# containers like ``limits`` / ``recommended-fields`` — cannot
# corrupt the shared registry caches.
merged = copy.deepcopy(_default_rules().get(rule_id, {}))
self._merge_layer(merged, self._profile_rules(rule_id))
overrides = self.rules.get(rule_id)
if overrides is None:
overrides = {}
merged = {**defaults, **overrides}
if overrides:
self._merge_layer(merged, overrides)
return merged

def is_rule_enabled(
Expand Down Expand Up @@ -451,13 +518,35 @@ def rule_enabled_reason(

# Deprecated rules never activate through auto detection or default
# enablement — only an explicit ``enabled: true`` (handled above) or
# a --rule flag (which bypasses this method) runs them.
# a --rule flag (which bypasses this method) runs them. Checked
# before the profile layer so a profile cannot resurrect one.
if deprecated is not None:
return False, (
f"deprecated since {deprecated} — will be removed in a future "
"release; set 'enabled: true' in config to keep running it"
)

# Profile-set ``enabled`` decides next, under the user's explicit
# setting (handled above) and above everything else. Profiles ship
# with the installed skillsaw, so their decisions bypass the config
# ``version`` gate — choosing one is an explicit opt-in to its rule
# set. That applies to a profile-set ``"auto"`` too: it falls
# through to the detection logic below, but skips the gate (see
# ``profile_sets_enabled``). Severity/parameter-only profile
# entries never change activation.
# ``has_explicit_enabled`` also covers a user ``enabled: "auto"`` —
# any explicit user setting replaces the profile's, so "auto" falls
# through to detection rather than to the profile's decision.
profile_sets_enabled = False
if not has_explicit_enabled:
profile_overrides = self._profile_rules(rule_id)
profile_sets_enabled = "enabled" in profile_overrides
profile_enabled = profile_overrides.get("enabled")
if profile_enabled is True:
return True, f"enabled: true set by profile '{self.profile}'"
if profile_enabled is False:
return False, f"disabled by profile '{self.profile}'"

if not has_explicit_enabled:
# Any non-enabled override (e.g. severity) without an explicit
# ``enabled`` key on a disabled-by-default rule implies the user
Expand All @@ -473,10 +562,12 @@ def rule_enabled_reason(
# activation — fall through to version gate + auto logic.

# Any explicit user override (enabled or otherwise) implies the user
# wants this rule, so skip the version gate.
# wants this rule, so skip the version gate. A profile-set
# ``enabled`` (only "auto" reaches here) skips it the same way —
# the profile's activation decisions are version-independent.
has_user_overrides = bool(user_overrides)

if not has_user_overrides and self.version:
if not has_user_overrides and not profile_sets_enabled and self.version:
if _parse_version(self.version) < _parse_version(since_version):
return False, (
f"config version {self.version} is older than the rule "
Expand Down Expand Up @@ -514,6 +605,8 @@ def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {}
if self.version:
d["version"] = self.version
if self._serializable_profile():
d["profile"] = self.profile
d["rules"] = self.rules
d["custom-rules"] = self.custom_rules
d["exclude"] = self.exclude_patterns
Expand Down Expand Up @@ -547,6 +640,22 @@ def save(self, config_path: Path):
f.write("# https://github.com/stbenjam/skillsaw\n\n")
if self.version:
f.write(f'version: "{self.version}"\n\n')
f.write(
"# Rule-set profile: a curated bundle of severity/enablement "
"overrides applied\n"
"# under your own 'rules:' entries. Available: "
+ ", ".join(available_profiles())
+ "\n"
"# NOTE: the explicit per-rule entries below count as your own "
"overrides and win\n"
"# over the profile — to adopt one, also delete the rule "
"entries you want the\n"
"# profile to manage.\n"
)
if self._serializable_profile():
f.write(f"profile: {self._yaml_value(self.profile)}\n\n")
else:
f.write("# profile: claude-5\n\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep generated defaults from shadowing the selected profile

When a user follows this generated comment—or edits .skillsaw.yaml.example—by uncommenting profile: claude-5, the file already contains explicit enabled and severity entries for every rule from LinterConfig.default(). Since user rule entries take precedence, these generated defaults suppress the profile's severity changes and its enable/disable decisions; only the parameter override survives. Consequently, skillsaw init users cannot activate the advertised one-line bundle without deleting the relevant generated rule entries, so the generated configuration needs to preserve profile applicability rather than advertising this uncomment as sufficient.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in e418d17 by making the advertisement honest rather than restructuring generated output: the generated header (and a new docs note) now says the explicit per-rule entries below count as user overrides and win over the profile, so adopting one also means deleting the entries the profile should manage. Changing skillsaw init to stop materializing per-rule defaults, or making profiles beat default-identical user entries, would change long-standing generated-config semantics — left as a maintainer call.


Generated by Claude Code

f.write("rules:\n")
for rule_id, rule_config in self.rules.items():
desc = descriptions.get(rule_id, "")
Expand Down
Loading