Skip to content

reach.lint

Static validation engine for skill manifests, frontmatter schemas, naming conventions, and listing budgets.

Provide static pre-flight linting for skill definitions and catalogs.

RULES module-attribute

RULES: dict[str, RuleDefinition] = {
    "invalid-yaml": RuleDefinition(
        rule="invalid-yaml",
        default_severity=Severity.ERROR,
        summary="SKILL.md contains missing or unparseable YAML frontmatter",
        explanation="Agent runtimes parse frontmatter metadata to discover skills. Malformed YAML prevents the skill from being indexed or loaded.",
        remedy="Ensure the file begins with '---' delimiters and contains valid YAML syntax.",
    ),
    "missing-name": RuleDefinition(
        rule="missing-name",
        default_severity=Severity.ERROR,
        summary="Frontmatter does not declare a skill 'name'",
        explanation="A skill must have an explicit identifier for agent catalog registration and invocation dispatch.",
        remedy="Add a 'name' field to the frontmatter matching the skill's directory name.",
    ),
    "missing-description": RuleDefinition(
        rule="missing-description",
        default_severity=Severity.ERROR,
        summary="Frontmatter has no 'description' or the description is empty",
        explanation="Agent models read descriptions to determine whether a skill applies to a user query. A missing description renders the skill unselectable.",
        remedy="Add a descriptive 'description' field stating what the skill does and when to use it.",
    ),
    "invalid-name-format": RuleDefinition(
        rule="invalid-name-format",
        default_severity=Severity.ERROR,
        summary="Skill name does not adhere to lowercase kebab-case convention",
        explanation="Standard skill runtimes expect lowercase alphanumeric identifiers separated by single hyphens (max 64 chars).",
        remedy="Rename the skill to use only lowercase letters, digits, and hyphens (e.g. 'git-workflow').",
    ),
    "name-mismatch": RuleDefinition(
        rule="name-mismatch",
        default_severity=Severity.ERROR,
        summary="Frontmatter 'name' differs from the parent directory name",
        explanation="Mismatched directory and manifest names cause discovery anomalies when runtimes load skills by folder name.",
        remedy="Align the frontmatter 'name' with the enclosing directory name.",
    ),
    "duplicate-name": RuleDefinition(
        rule="duplicate-name",
        default_severity=Severity.ERROR,
        summary="Multiple skills in the corpus declare the same name",
        explanation="Duplicate skill names cause nondeterministic catalog collisions and directory shadowing.",
        remedy="Rename conflicting skills so every skill in the corpus has a distinct name.",
    ),
    "duplicate-capability": RuleDefinition(
        rule="duplicate-capability",
        default_severity=Severity.WARN,
        summary="Skill description has high semantic overlap (> 92%) with another skill",
        explanation="Descriptions with near-identical semantic vectors create ambiguous attractor basins that lead to misroutes and non-deterministic skill selection.",
        remedy="Differentiate the skill descriptions by clarifying distinct trigger boundaries or consolidating redundant skills.",
    ),
    "description-too-short": RuleDefinition(
        rule="description-too-short",
        default_severity=Severity.WARN,
        summary="Description is too brief to provide actionable routing criteria",
        explanation="Descriptions under 20 characters lack the context and trigger conditions agent models need to reliably route queries.",
        remedy="Expand the description to clearly describe the skill's capabilities and trigger scenarios.",
    ),
    "unresolved-placeholder": RuleDefinition(
        rule="unresolved-placeholder",
        default_severity=Severity.WARN,
        summary="Description contains unresolved template placeholders",
        explanation="Markers like TODO, FIXME, or <FILL_IN> in descriptions distract models and degrade selection accuracy.",
        remedy="Replace template markers with concrete guidance describing actual skill capabilities.",
    ),
    "reserved-name-collision": RuleDefinition(
        rule="reserved-name-collision",
        default_severity=Severity.WARN,
        summary="Skill name collides with a built-in agent tool or primitive",
        explanation="Naming a skill after a built-in command (e.g. 'bash', 'edit', 'read') confuses tool selection routing.",
        remedy="Rename the skill to describe the specific domain task (e.g. 'bash-script-runner').",
    ),
    "listing-overflow": RuleDefinition(
        rule="listing-overflow",
        default_severity=Severity.WARN,
        summary="Skill description is unusually long and risks runtime truncation",
        explanation="Agent runtimes enforce strict listing budgets on catalog context. Excessively verbose descriptions risk truncation.",
        remedy="Condense the description to highlight key triggers, moving extensive documentation into the markdown body.",
    ),
    "unresolved-declared-dependency": RuleDefinition(
        rule="unresolved-declared-dependency",
        default_severity=Severity.WARN,
        summary="Declared dependency skill is missing from catalog",
        explanation="A skill declared in metadata.requires_skill or allowed-tools: Skill(X) does not exist in the resident catalog or discovery roots.",
        remedy="Ensure the required skill is installed or update the dependency declaration.",
    ),
    "lockfile-drift": RuleDefinition(
        rule="lockfile-drift",
        default_severity=Severity.INFO,
        summary="SKILL.md digest does not match lockfile computedHash",
        explanation="The skill contents have changed locally since being pinned in skills-lock.json.",
        remedy="Re-run npx skills update or refresh the lockfile hash.",
    ),
    "unbounded-attractor": RuleDefinition(
        rule="unbounded-attractor",
        default_severity=Severity.WARN,
        summary="Description uses greedy or universal phrasing that hijacks queries",
        explanation="Descriptions claiming unbounded scope (e.g. 'assist with any task' or 'manage files and run commands') act as greedy attractor sinks in multi-skill catalogs, causing distractor hijacking.",
        remedy="Narrow the description to specific domains, tools, and trigger conditions, and add directional disclaimers specifying when not to invoke the skill.",
    ),
    "unknown-skill-reference": RuleDefinition(
        rule="unknown-skill-reference",
        default_severity=Severity.WARN,
        summary="Description hands off to a skill name that does not exist in the catalog",
        explanation="Negative routing instructions (e.g. 'Don't use for X — use <other-skill>') that reference a missing or unmerged skill actively repel the router away from the resident skill while the target skill is absent, creating a 0% recall sinkhole.",
        remedy="Remove the handoff reference until the target skill is added to the catalog, or correct the referenced skill name.",
    ),
    "missing-mutual-handoff": RuleDefinition(
        rule="missing-mutual-handoff",
        default_severity=Severity.WARN,
        summary="Overlapping neighbor skills lack mutual routing handoffs ('use <other-skill>')",
        explanation="When closely related skills share domain vocabulary or one skill defines a one-way boundary without a reciprocal handoff on the neighbor, the unguarded skill acts as a one-way attractor sink and hijacks queries.",
        remedy="Add reciprocal 'Don't use for X (use <neighbor-skill>)' handoff clauses to both overlapping skills so each carves out the other's territory.",
    ),
    "catalog-budget-overflow": RuleDefinition(
        rule="catalog-budget-overflow",
        default_severity=Severity.WARN,
        summary="Skill catalog exceeds runtime resident listing budget",
        explanation="When resident skills exceed the agent runtime's skill listing budget (e.g. 30,000 chars for Claude Code), descriptions are truncated to bare names during evaluation and routing, reducing discovery accuracy.",
        remedy="Partition catalog with 'reach cluster', shorten skill descriptions, or set skill_listing_budget_fraction.",
    ),
    "empty-skill-directory": RuleDefinition(
        rule="empty-skill-directory",
        default_severity=Severity.INFO,
        summary="Skill directory contains no SKILL.md manifest file",
        explanation="Empty or uninitialized child directories inside a skill catalog root are ignored by discovery and may indicate an incomplete skill checkout.",
        remedy="Add a SKILL.md manifest to the directory or remove the empty folder.",
    ),
}

LintIssue

Bases: BaseModel

Represent a single diagnostic finding for a skill file or catalog.

Source code in src/reach/lint.py
class LintIssue(BaseModel):
    """Represent a single diagnostic finding for a skill file or catalog."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    rule: str
    severity: Severity
    skill: str
    path: Path | None = None
    message: str
    remedy: str = ""
    line: int | None = None

LintReport

Bases: BaseModel

Aggregate lint issues across all evaluated skills.

Source code in src/reach/lint.py
class LintReport(BaseModel):
    """Aggregate lint issues across all evaluated skills."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    skill_name: str | None = None
    skills_checked: int = 0
    issues: tuple[LintIssue, ...] = ()

    @property
    def errors(self) -> tuple[LintIssue, ...]:
        """Filter report issues to return only those with ERROR severity."""
        return tuple(issue for issue in self.issues if issue.severity == Severity.ERROR)

    @property
    def warnings(self) -> tuple[LintIssue, ...]:
        """Filter report issues to return only those with WARN severity."""
        return tuple(issue for issue in self.issues if issue.severity == Severity.WARN)

    @property
    def infos(self) -> tuple[LintIssue, ...]:
        """Filter report issues to return only those with INFO severity."""
        return tuple(issue for issue in self.issues if issue.severity == Severity.INFO)

    @property
    def clean(self) -> bool:
        """Return True if no lint issues of any severity were found."""
        return not self.issues

    @property
    def has_errors(self) -> bool:
        """Return True if one or more errors were recorded."""
        return bool(self.errors)

clean property

clean: bool

Return True if no lint issues of any severity were found.

errors property

errors: tuple[LintIssue, ...]

Filter report issues to return only those with ERROR severity.

has_errors property

has_errors: bool

Return True if one or more errors were recorded.

infos property

infos: tuple[LintIssue, ...]

Filter report issues to return only those with INFO severity.

warnings property

warnings: tuple[LintIssue, ...]

Filter report issues to return only those with WARN severity.

LintSettings

Bases: BaseModel

Configuration settings for static skill linting and validation thresholds.

Source code in src/reach/config.py
class LintSettings(BaseModel):
    """Configuration settings for static skill linting and validation thresholds."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    max_description_length: Annotated[int, Field(ge=1)] = 1024
    max_name_length: Annotated[int, Field(ge=1)] = 64
    min_description_length: Annotated[int, Field(ge=1)] = 20
    catalog_budget_chars: Annotated[int, Field(ge=1)] | None = DEFAULT_CATALOG_BUDGET_CHARS
    similarity_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.92
    mutual_handoff_similarity_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.75
    mutual_handoff_lexical_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.35
    rules: dict[str, RuleSeverity] = Field(default_factory=dict)

    def with_rule_overrides(
        self,
        overrides: Mapping[str, Any] | None = None,
    ) -> LintSettings:
        """Return a validated copy of LintSettings with rule severity overrides applied."""
        if not overrides:
            return self
        merged_rules = {**self.rules, **overrides}
        validated_rules = (
            type(self).model_validate({**self.model_dump(), "rules": merged_rules}).rules
        )
        return self.model_copy(update={"rules": validated_rules})

    @classmethod
    def from_settings(
        cls,
        settings: Mapping[str, object] | None = None,
        overrides: Mapping[str, Any] | None = None,
    ) -> LintSettings:
        """Construct a LintSettings from loaded reach.toml settings and CLI overrides."""
        if settings is None:
            settings = load_config()

        lint_section = settings.get("lint", {}) if isinstance(settings, Mapping) else {}
        retrieval_section = settings.get("retrieval", {}) if isinstance(settings, Mapping) else {}
        catalog_section = settings.get("catalog", {}) if isinstance(settings, Mapping) else {}

        payload: dict[str, Any] = dict(lint_section) if isinstance(lint_section, Mapping) else {}
        raw_rules = payload.get("rules")
        rules: dict[str, Any] = dict(raw_rules) if isinstance(raw_rules, Mapping) else {}
        if overrides:
            rules.update(overrides)
        if rules or "rules" in payload:
            payload["rules"] = rules

        lint_base = cls.model_validate(payload)
        catalog_cfg = CatalogSettings.model_validate(
            catalog_section if isinstance(catalog_section, Mapping) else {}
        )
        retrieval_cfg = RetrievalSettings.model_validate(
            retrieval_section if isinstance(retrieval_section, Mapping) else {}
        )
        return _apply_lint_inheritance(lint_base, catalog_cfg, retrieval_cfg)

from_settings classmethod

from_settings(
    settings: Mapping[str, object] | None = None,
    overrides: Mapping[str, Any] | None = None,
) -> LintSettings

Construct a LintSettings from loaded reach.toml settings and CLI overrides.

Source code in src/reach/config.py
@classmethod
def from_settings(
    cls,
    settings: Mapping[str, object] | None = None,
    overrides: Mapping[str, Any] | None = None,
) -> LintSettings:
    """Construct a LintSettings from loaded reach.toml settings and CLI overrides."""
    if settings is None:
        settings = load_config()

    lint_section = settings.get("lint", {}) if isinstance(settings, Mapping) else {}
    retrieval_section = settings.get("retrieval", {}) if isinstance(settings, Mapping) else {}
    catalog_section = settings.get("catalog", {}) if isinstance(settings, Mapping) else {}

    payload: dict[str, Any] = dict(lint_section) if isinstance(lint_section, Mapping) else {}
    raw_rules = payload.get("rules")
    rules: dict[str, Any] = dict(raw_rules) if isinstance(raw_rules, Mapping) else {}
    if overrides:
        rules.update(overrides)
    if rules or "rules" in payload:
        payload["rules"] = rules

    lint_base = cls.model_validate(payload)
    catalog_cfg = CatalogSettings.model_validate(
        catalog_section if isinstance(catalog_section, Mapping) else {}
    )
    retrieval_cfg = RetrievalSettings.model_validate(
        retrieval_section if isinstance(retrieval_section, Mapping) else {}
    )
    return _apply_lint_inheritance(lint_base, catalog_cfg, retrieval_cfg)

with_rule_overrides

with_rule_overrides(
    overrides: Mapping[str, Any] | None = None,
) -> LintSettings

Return a validated copy of LintSettings with rule severity overrides applied.

Source code in src/reach/config.py
def with_rule_overrides(
    self,
    overrides: Mapping[str, Any] | None = None,
) -> LintSettings:
    """Return a validated copy of LintSettings with rule severity overrides applied."""
    if not overrides:
        return self
    merged_rules = {**self.rules, **overrides}
    validated_rules = (
        type(self).model_validate({**self.model_dump(), "rules": merged_rules}).rules
    )
    return self.model_copy(update={"rules": validated_rules})

RuleDefinition

Bases: BaseModel

Describe a static lint rule, its rationale, and recommended remediation.

Source code in src/reach/lint.py
class RuleDefinition(BaseModel):
    """Describe a static lint rule, its rationale, and recommended remediation."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    rule: str
    default_severity: Severity
    summary: str
    explanation: str
    remedy: str

Severity

Bases: StrEnum

Specify the severity level for a lint diagnostic.

Source code in src/reach/config.py
class Severity(StrEnum):
    """Specify the severity level for a lint diagnostic."""

    ERROR = "error"
    IGNORE = "ignore"
    INFO = "info"
    WARN = "warn"

explain_rule

explain_rule(rule_name: str) -> RuleDefinition | None

Retrieve documentation and remediation advice for a named lint rule.

Source code in src/reach/lint.py
def explain_rule(rule_name: str) -> RuleDefinition | None:
    """Retrieve documentation and remediation advice for a named lint rule."""
    return RULES.get(rule_name)

lint_file

lint_file(
    skill_file: Path | str,
    config: LintSettings | None = None,
) -> LintReport

Inspect a single SKILL.md manifest file for structural and authoring issues.

Source code in src/reach/lint.py
def lint_file(skill_file: Path | str, config: LintSettings | None = None) -> LintReport:
    """Inspect a single SKILL.md manifest file for structural and authoring issues."""
    path = resolve_path(skill_file)
    cfg = config if config is not None else LintSettings.from_settings()
    dir_name = path.parent.name
    issues: list[LintIssue] = []

    try:
        content = path.read_text(encoding="utf-8")
    except OSError as exc:
        msg = f"Failed to read SKILL.md: {exc}"
        _record_issue(issues, "invalid-yaml", dir_name, path, msg, cfg)
        return LintReport(issues=tuple(issues), skills_checked=1, skill_name=dir_name)

    split = split_frontmatter(content)
    if split is None:
        _record_issue(
            issues,
            "invalid-yaml",
            dir_name,
            path,
            "File lacks valid YAML frontmatter surrounded by '---' delimiters.",
            cfg,
        )
        return LintReport(issues=tuple(issues), skills_checked=1, skill_name=dir_name)

    frontmatter_text, _body = split
    try:
        loaded = yaml.safe_load(frontmatter_text)
    except yaml.YAMLError as exc:
        _record_issue(
            issues,
            "invalid-yaml",
            dir_name,
            path,
            f"YAML parsing error in frontmatter: {exc}",
            cfg,
        )
        return LintReport(issues=tuple(issues), skills_checked=1, skill_name=dir_name)

    if not isinstance(loaded, dict):
        _record_issue(
            issues,
            "invalid-yaml",
            dir_name,
            path,
            "Frontmatter YAML must be a mapping/dictionary of key-value pairs.",
            cfg,
        )
        return LintReport(issues=tuple(issues), skills_checked=1, skill_name=dir_name)

    field_issues, skill_name = _lint_frontmatter_dict(loaded, dir_name, path, cfg)
    issues.extend(field_issues)
    _check_lockfile_drift(path, skill_name, cfg, issues)

    return LintReport(issues=tuple(issues), skills_checked=1, skill_name=skill_name)

lint_skills

lint_skills(
    skills: Sequence[Path | str],
    config: LintSettings | None = None,
) -> LintReport

Lint a specific collection of skill directories or SKILL.md file paths.

Parameters:

Name Type Description Default
skills Sequence[Path | str]

Sequence of paths to skill directories or SKILL.md files.

required
config LintSettings | None

Optional LintSettings overrides; loads from reach.toml settings if omitted.

None

Returns:

Type Description
LintReport

A LintReport containing all detected issues, severities, and skill counts.

Source code in src/reach/lint.py
def lint_skills(
    skills: Sequence[Path | str],
    config: LintSettings | None = None,
) -> LintReport:
    """Lint a specific collection of skill directories or SKILL.md file paths.

    Args:
        skills: Sequence of paths to skill directories or SKILL.md files.
        config: Optional LintSettings overrides; loads from reach.toml settings if omitted.

    Returns:
        A LintReport containing all detected issues, severities, and skill counts.
    """
    cfg = config if config is not None else LintSettings.from_settings()
    resolved_files: list[Path] = []
    for item in skills:
        path = resolve_path(item)
        target = path / "SKILL.md" if path.is_dir() else path
        if target.is_file():
            resolved_files.append(target)
    return _lint_paths(resolved_files, cfg)

lint_tree

lint_tree(
    root: Path | str,
    config: LintSettings | None = None,
    skill_filter: str | None = None,
) -> LintReport

Recursively search for and lint all SKILL.md files under a directory root.

Parameters:

Name Type Description Default
root Path | str

Directory root to search for skills.

required
config LintSettings | None

Optional LintSettings overrides; loads from reach.toml settings if omitted.

None
skill_filter str | None

Optional skill name filter to limit reported diagnostics.

None

Returns:

Type Description
LintReport

A LintReport summarizing all issues found across discovered skills.

Source code in src/reach/lint.py
def lint_tree(
    root: Path | str,
    config: LintSettings | None = None,
    skill_filter: str | None = None,
) -> LintReport:
    """Recursively search for and lint all SKILL.md files under a directory root.

    Args:
        root: Directory root to search for skills.
        config: Optional LintSettings overrides; loads from reach.toml settings if omitted.
        skill_filter: Optional skill name filter to limit reported diagnostics.

    Returns:
        A LintReport summarizing all issues found across discovered skills.
    """
    resolved_root = resolve_path(root)
    cfg = config if config is not None else LintSettings.from_settings()
    report = _lint_paths(
        _skill_files(resolved_root),
        cfg,
        skill_filter=skill_filter,
    )
    if resolved_root.is_dir():
        child_dirs = sorted(
            d for d in resolved_root.iterdir() if d.is_dir() and not d.name.startswith(".")
        )
        if any((d / "SKILL.md").is_file() for d in child_dirs):
            empty_issues: list[LintIssue] = []
            for d in child_dirs:
                if not (d / "SKILL.md").is_file():
                    if skill_filter is not None and d.name != skill_filter:
                        continue
                    _record_issue(
                        empty_issues,
                        "empty-skill-directory",
                        d.name,
                        d,
                        f"Directory {d.name!r} inside skills root contains no SKILL.md file.",
                        cfg,
                    )
            if empty_issues:
                merged = sorted(
                    (*report.issues, *empty_issues),
                    key=lambda i: (str(i.path or ""), i.line or 0, i.rule),
                )
                return LintReport(
                    issues=tuple(merged),
                    skills_checked=report.skills_checked,
                    skill_name=report.skill_name,
                )
    return report