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.WARN,
        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.",
    ),
}

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)

    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)

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

    @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 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.

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: int = Field(default=1024, ge=1)
    max_name_length: int = Field(default=64, ge=1)
    min_description_length: int = Field(default=20, ge=1)
    similarity_threshold: float = Field(default=0.92, ge=0.0, le=1.0)
    mutual_handoff_similarity_threshold: float = Field(default=0.75, ge=0.0, le=1.0)
    mutual_handoff_lexical_threshold: float = Field(default=0.35, ge=0.0, le=1.0)
    rules: dict[str, Any] = Field(default_factory=dict)

    @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 {}
        defaults = cls()

        max_desc = defaults.max_description_length
        max_name = defaults.max_name_length
        min_desc = defaults.min_description_length
        sim_threshold = defaults.similarity_threshold
        rules: dict[str, Any] = {}

        if isinstance(lint_section, Mapping):
            int_vals = {
                k: v
                for k in (
                    "max_description_length",
                    "max_name_length",
                    "min_description_length",
                )
                if isinstance(v := lint_section.get(k), int)
            }
            max_desc = int_vals.get("max_description_length", max_desc)
            max_name = int_vals.get("max_name_length", max_name)
            min_desc = int_vals.get("min_description_length", min_desc)
            raw_sim = lint_section.get("similarity_threshold")
            if isinstance(raw_sim, (int, float)):
                sim_threshold = float(raw_sim)
            raw_rules = lint_section.get("rules")
            if isinstance(raw_rules, Mapping):
                rules = dict(raw_rules)

        if isinstance(retrieval_section, Mapping):
            raw_sim = retrieval_section.get("similarity_threshold")
            if isinstance(raw_sim, (int, float)):
                sim_threshold = float(raw_sim)

        if overrides:
            rules.update(overrides)

        return cls(
            max_description_length=max_desc,
            max_name_length=max_name,
            min_description_length=min_desc,
            similarity_threshold=sim_threshold,
            rules=rules,
        )

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 {}
    defaults = cls()

    max_desc = defaults.max_description_length
    max_name = defaults.max_name_length
    min_desc = defaults.min_description_length
    sim_threshold = defaults.similarity_threshold
    rules: dict[str, Any] = {}

    if isinstance(lint_section, Mapping):
        int_vals = {
            k: v
            for k in (
                "max_description_length",
                "max_name_length",
                "min_description_length",
            )
            if isinstance(v := lint_section.get(k), int)
        }
        max_desc = int_vals.get("max_description_length", max_desc)
        max_name = int_vals.get("max_name_length", max_name)
        min_desc = int_vals.get("min_description_length", min_desc)
        raw_sim = lint_section.get("similarity_threshold")
        if isinstance(raw_sim, (int, float)):
            sim_threshold = float(raw_sim)
        raw_rules = lint_section.get("rules")
        if isinstance(raw_rules, Mapping):
            rules = dict(raw_rules)

    if isinstance(retrieval_section, Mapping):
        raw_sim = retrieval_section.get("similarity_threshold")
        if isinstance(raw_sim, (int, float)):
            sim_threshold = float(raw_sim)

    if overrides:
        rules.update(overrides)

    return cls(
        max_description_length=max_desc,
        max_name_length=max_name,
        min_description_length=min_desc,
        similarity_threshold=sim_threshold,
        rules=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)

    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/lint.py
class Severity(StrEnum):
    """Specify the severity level for a lint diagnostic."""

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

SkillLintSemantics

Bases: BaseModel

Represent structured routing boundaries and scope attractors for a skill.

Source code in src/reach/_lint_semantics.py
class SkillLintSemantics(BaseModel):
    """Represent structured routing boundaries and scope attractors for a skill."""

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

    skill: str
    handoff_targets: tuple[str, ...] = ()
    unbounded_attractor_phrase: str | None = None

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)

extract_corpus_semantics

extract_corpus_semantics(
    skills: Sequence[Skill],
) -> dict[str, SkillLintSemantics]

Extract routing handoff targets and attractor semantics deterministically across a corpus.

Source code in src/reach/_lint_semantics.py
def extract_corpus_semantics(
    skills: Sequence[Skill],
) -> dict[str, SkillLintSemantics]:
    """Extract routing handoff targets and attractor semantics deterministically across a corpus."""
    results: dict[str, SkillLintSemantics] = {}
    for skill in skills:
        if not _HANDOFF_CANDIDATE_PREFILTER_RE.search(skill.description):
            continue
        results[skill.name] = SkillLintSemantics(
            skill=skill.name,
            handoff_targets=extract_skill_references(skill.description, self_name=skill.name),
            unbounded_attractor_phrase=detect_unbounded_attractor(skill.description),
        )
    return results

extract_skill_references

extract_skill_references(
    description: str, *, self_name: str | None = None
) -> tuple[str, ...]

Extract explicit skill references from negative/redirect clauses in a description.

Uses grammatical noun-position boundaries: a kebab-case token after a positive handoff verb (use, see, prefer, defer to) must either be enclosed in backticks or stand in terminal noun position (followed by clause punctuation, instead, first, singular skill, or or/and to another skill). Compound adjectives modifying a following noun (e.g. use product-specific skills) are excluded structurally without word blocklists.

Parameters:

Name Type Description Default
description str

Frontmatter description string to inspect.

required
self_name str | None

Optional name of the skill itself to exclude self-references.

None

Returns:

Type Description
tuple[str, ...]

Sorted tuple of unique referenced skill names in kebab-case.

Source code in src/reach/_lint_semantics.py
def extract_skill_references(
    description: str,
    *,
    self_name: str | None = None,
) -> tuple[str, ...]:
    """Extract explicit skill references from negative/redirect clauses in a description.

    Uses grammatical noun-position boundaries: a kebab-case token after a positive
    handoff verb (`use`, `see`, `prefer`, `defer to`) must either be enclosed in
    backticks or stand in terminal noun position (followed by clause punctuation,
    `instead`, `first`, singular `skill`, or `or`/`and` to another skill). Compound
    adjectives modifying a following noun (e.g. `use product-specific skills`)
    are excluded structurally without word blocklists.

    Args:
        description: Frontmatter description string to inspect.
        self_name: Optional name of the skill itself to exclude self-references.

    Returns:
        Sorted tuple of unique referenced skill names in kebab-case.
    """
    if not description or not description.strip():
        return ()

    self_lower = self_name.strip().lower() if self_name else None
    found: set[str] = set()

    for match in _PAREN_HANDOFF_RE.finditer(description):
        targets = match.group("targets")
        for m in _TARGET_TOKEN_RE.finditer(targets):
            _add_if_valid_ref(
                found,
                m.group("token"),
                self_lower,
                is_explicit_backtick=bool(m.group("bt")),
            )

    for match in _BACKTICK_HANDOFF_RE.finditer(description):
        _add_if_valid_ref(found, match.group(1), self_lower, is_explicit_backtick=True)

    for sentence in re.split(r"[.!?]+", description):
        if not _NEGATIVE_CLAUSE_MARKER_RE.search(sentence):
            continue
        for match in _VERB_TARGET_IN_CLAUSE_RE.finditer(sentence):
            targets = match.group("targets")
            for m in _TARGET_TOKEN_RE.finditer(targets):
                _add_if_valid_ref(
                    found,
                    m.group("token"),
                    self_lower,
                    is_explicit_backtick=bool(m.group("bt")),
                )

    return tuple(sorted(found))

find_competing_neighbors

find_competing_neighbors(
    modified: set[str],
    skills: Sequence[Skill],
    *,
    settings: LintSettings | None = None,
    dense_similarities: Mapping[tuple[str, str], float]
    | None = None,
    semantics_by_name: Mapping[str, SkillLintSemantics]
    | None = None,
) -> set[str]

Identify competing neighbor skills that could be hijacked by modified skills.

Source code in src/reach/lint.py
def find_competing_neighbors(
    modified: set[str],
    skills: Sequence[Skill],
    *,
    settings: LintSettings | None = None,
    dense_similarities: Mapping[tuple[str, str], float] | None = None,
    semantics_by_name: Mapping[str, SkillLintSemantics] | None = None,
) -> set[str]:
    """Identify competing neighbor skills that could be hijacked by modified skills."""
    if not modified or len(skills) < MIN_PAIRWISE_SKILLS:
        return set()

    from reach.overlap import rank_corpus

    cfg = settings if settings is not None else LintSettings.from_settings()
    lex_thresh = cfg.mutual_handoff_lexical_threshold
    sem_thresh = cfg.mutual_handoff_similarity_threshold
    sim_map = (
        dense_similarities
        if dense_similarities is not None
        else _compute_dense_similarities(skills)
    )
    overlap = rank_corpus(_positive_skills_corpus(skills))
    comp_by_name = {c.skill: c for c in overlap.competitions}
    by_name = {s.name: s for s in skills}
    refs_by_name = _resolve_refs_by_name(skills, semantics_by_name)
    taxonomy_tokens = _catalog_taxonomy_tokens(skills)
    neighbors: set[str] = set()

    for mod_name in modified:
        mod_skill = by_name.get(mod_name)
        if mod_skill is None:
            continue
        neighbors.update(refs_by_name.get(mod_name, frozenset()) & (set(by_name) - modified))
        for candidate in skills:
            if candidate.name in modified:
                continue
            sem_sim = _symmetric_dense_sim(mod_name, candidate.name, sim_map)
            lex_ratio = _max_lexical_ratio(mod_name, candidate.name, comp_by_name)
            if (
                lex_ratio >= lex_thresh
                or sem_sim >= sem_thresh
                or mod_name in refs_by_name.get(candidate.name, ())
                or _has_bidirectional_name_claim(mod_skill, candidate, taxonomy_tokens)
            ):
                neighbors.add(candidate.name)

    return neighbors

find_unknown_skill_references

find_unknown_skill_references(
    description: str,
    known_skills: Sequence[str] | set[str] | frozenset[str],
    *,
    self_name: str | None = None,
    settings: LintSettings | None = None,
    extracted_refs: Sequence[str]
    | frozenset[str]
    | None = None,
) -> tuple[str, ...]

Return referenced skill names in description that are absent from known_skills.

Respects the configured severity for unknown-skill-reference and returns an empty tuple when the rule is set to ignore.

Source code in src/reach/lint.py
def find_unknown_skill_references(
    description: str,
    known_skills: Sequence[str] | set[str] | frozenset[str],
    *,
    self_name: str | None = None,
    settings: LintSettings | None = None,
    extracted_refs: Sequence[str] | frozenset[str] | None = None,
) -> tuple[str, ...]:
    """Return referenced skill names in description that are absent from known_skills.

    Respects the configured severity for ``unknown-skill-reference`` and returns
    an empty tuple when the rule is set to ``ignore``.
    """
    cfg = settings if settings is not None else LintSettings.from_settings()
    if _resolve_severity("unknown-skill-reference", cfg) is None:
        return ()
    known_lower = {name.lower() for name in known_skills}
    refs = (
        tuple(extracted_refs)
        if extracted_refs is not None
        else extract_skill_references(description, self_name=self_name)
    )
    unknown: list[str] = []
    for ref in refs:
        if ref in known_lower:
            continue
        if _is_wildcard_prefix_in_description(description, ref) and any(
            k.startswith(f"{ref}-") for k in known_lower
        ):
            continue
        unknown.append(ref)
    return tuple(unknown)

hands_off_to_skill

hands_off_to_skill(
    source_description: str,
    target_name: str,
    extracted_refs: frozenset[str] | None = None,
) -> bool

Return True if source_description explicitly hands off to or disclaims target_name.

Source code in src/reach/lint.py
def hands_off_to_skill(
    source_description: str,
    target_name: str,
    extracted_refs: frozenset[str] | None = None,
) -> bool:
    """Return True if source_description explicitly hands off to or disclaims target_name."""
    target_lower = target_name.lower()
    refs = (
        extracted_refs
        if extracted_refs is not None
        else frozenset(extract_skill_references(source_description))
    )
    if target_lower in refs:
        return True
    if any(
        target_lower.startswith(f"{ref}-")
        and _is_wildcard_prefix_in_description(source_description, ref)
        for ref in refs
    ):
        return True

    from reach.leak import contains_run
    from reach.retrieval import tokenize

    wanted = tokenize(target_lower)
    if not wanted:
        return False

    explicit_target_re = re.compile(
        rf"\b(?:use|see|prefer|refer\s+to|defer\s+to|delegate\s+to|switch\s+to)\s+(?:the\s+)?"
        rf"`?{re.escape(target_lower)}`?(?:\s+skill|\s+instead|\s+first|[).,;:]|$)",
        re.IGNORECASE,
    )
    backtick_target_re = re.compile(rf"`{re.escape(target_lower)}`", re.IGNORECASE)
    stem = (
        wanted[:-1]
        if len(wanted) >= _MIN_STEM_TOKENS and wanted[-1] in _META_NAME_SUFFIXES
        else wanted
    )

    for sentence in re.split(r"[.!?]+", source_description):
        if explicit_target_re.search(sentence):
            return True
        if _HANDOFF_SENTENCE_RE.search(sentence):
            sent_tokens = tokenize(sentence)
            if backtick_target_re.search(sentence) or (
                len(wanted) >= _MIN_DISTINCTIVE_NAME_TOKENS
                and (
                    contains_run(sent_tokens, wanted)
                    or (
                        len(stem) >= _MIN_DISTINCTIVE_NAME_TOKENS
                        and contains_run(sent_tokens, stem)
                    )
                )
            ):
                return True
    return False

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()
    return _lint_paths(
        _skill_files(resolved_root),
        cfg,
        skill_filter=skill_filter,
    )