Instruction file imported from stbenjam/skillsaw (
.cursor/rules/development.mdc). Copyright stays with the author.
Follow these rules when developing skillsaw, a configurable, rule-based linter for agentic contextual building blocks.
Pre-push Checklist
CRITICAL: You MUST always run these steps before pushing changes.
- Ensure branch is up to date with UPSTREAM (stbenjam/skillsaw) — check git remotes then merge upstream's main.
- Run
make test— run the full test suite and ensure all tests pass. Never assume a problem is pre-existing; follow the boyscout rule and fix it anyway. - Run
make lint— check formatting (or runmake formatto fix it). - Run
make update— regenerate all generated files (must come after version bump). - Test against
openshift-eng/ai-helpers: clone it, runskillsaw, and ensure exit 0.
Pre-PR Checklist
Follow these checks before opening a PR:
- Check whether documentation is up to date for the changes on this branch.
- Example: you add a new feature, flag, or lint type.
Action: update
README.mdto include this. - Example: you encounter a problem during development you must keep for later.
Action: update
.apm/instructionsand runmake update.
- Example: you add a new feature, flag, or lint type.
Action: update
- Verify test coverage on a new feature, flag, lint type or bug fix is complete.
- Bug fixes must include regression protection.
- New features, linters, and rules need integration test coverage WITH fixtures (see testing rules).
Authoring Agentic Context
When you create or edit a skill, slash command, agent, hook, or instruction
file in this repo, load the skillsaw-lint skill and follow it: lint the
file with .venv/bin/skillsaw lint <path>, apply fixes, and re-lint until
clean before you finish. We ship the linter — our own context must pass it.
Post-PR Checklist
After opening a PR, keep monitoring for feedback from CodeRabbit, Gemini, and stbenjam. You may stop monitoring 20 minutes after you push a PR. Review and handle valid feedback that comes in.
Performance
Benchmark lint speed with the harness in benchmarks/ (make benchmark, make profile, make benchmark-save / make benchmark-compare
for regression checks — review DEVELOPMENT.md). When touching content rules,
the lint tree, or utils.py read paths, save a baseline on main and compare on your branch.
- Avoid scanning every line against every regex pattern.
Run
patterns_matching_anywhere(body, patterns)fromcontent_analysisfirst to filter down to patterns that actually match the text, then scan line-by-line only with surviving patterns. lint_tree.find(NodeType)is memoized per node — keep it valid: anything that mutates tree structure outside a rebuild must callinvalidate_find_cache()(seeFrontmatteredBlock.write_frontmatter_text).- Read top-level frontmatter key lines from
frontmatter_key_line(), which uses a libyaml-backed line map with a ruamel fallback. Don't add per-key ruamel parses. Avoid ruamel round-trip parsing — it is ~30x slower and was the dominant cost of lint-tree construction. - Keep per-blob work (whole-body
.lower(), config-file stats) outside the per-block loop — run it once percheck(). - Use fast string checks before running heavy regexes. Simple checks
like
str.isascii(),str.translate(), or"/" in textquickly filter out clean text so regexes only run when necessary. - Use
paths.relative_to_str()for repo-relative paths. On Python 3.12+,PurePath.relative_toandpath.parentsallocate newPathobjects for every ancestor, which adds significant overhead in large repos. - Load YAML with
yaml.load(..., Loader=_SAFE_LOADER)fromutils.pyinstead of bareyaml.safe_load—_SAFE_LOADERuses the fast C-based LibYAML loader when available (~10x faster) while maintaining safe loading behavior. Linter.run()/fix()pause Python's cyclic garbage collector during rule execution to avoid expensive GC pauses across millions of objects. Rules should avoid circular references so temporary data is freed immediately via reference counting.
Markdown: AST for reading, splice for writing
Read markdown structure (links, code spans, fences, HTML comments, headings)
from the markdown-it-py AST — read it via block.markdown (a
skillsaw.markdown_doc.MarkdownDoc). Never hand-roll per-line regexes for markdown structure.
- Detection: use the
MarkdownDocaccessors —links(),code_spans(),text_segments(),fences(),headings(),html_comments(),prose_lines(). Scanning rules needing only prose read it automatically throughread_body(strip_code_blocks=True). - Fixes: splice at the token spans the check matched using
markdown_doc.splice(content, edits)— never re-locate fix targets withline.find()/str.replace()— avoid them; they corrupt substrings of other tokens. - Never render the AST back to markdown — round-trip rendering reformats whole files and violates the scope-the-fix autofix rule.
- Use
markdown_doc.file_span()to translate body-relative columns to file columns before splicing (YAML-embedded bodies are indented in the file); skip the edit — return early — whenfile_span()yieldsNone.
New Linter Rules
- Make rules configurable when there are tuneable settings.
- Read declared options with
self.setting()so overrides, schema defaults, and explicit nulls follow one contract. Declare every literal config key the rule reads;tests/test_rule_registry.pyenforces this for builtin rule class bodies. - Never break existing rules for users of skillsaw.
- Rules register themselves — never hand-maintain import lists or config
dicts. skillsaw auto-discovers any concrete
Rulesubclass undersrc/skillsaw/rules/builtin/. Keep defaults on the class:default_enabledanddefault_severity()define the single source of truth, andLinterConfig.default()is generated from them. - New rules default to
enabled: autoorenabled: false. Setdefault_enabled = Falsefor opt-in; the base class default is"auto". Never force-enable a new rule that could break existing users. - Use the lint tree for discovery — call
context.lint_tree.find(NodeType).
Making rules helpful by default
Setting enabled: auto means a rule provides clear, actionable value on typical repositories without extra setup. Before shipping a rule with auto enabled, test it against real-world repositories rather than just unit test fixtures.
Evaluate every new rule against these three practical questions:
- Is it actionable on common setups? Findings should point to things developers genuinely want to fix. If a check only applies to niche or rare configurations, make it opt-in (
default_enabled = False). Be especially mindful of JSON files, which lack inline disable comments and require configuration tweaks to silence. - Is it accurate? Test the rule against at least 10 real repositories. Validate edge cases directly against the official schema, loader, or CLI rather than memory. Aim for a false-positive rate under 10% for
autorules, and zero known false positives forERRORrules. - Is it high signal? A rule that fires dozens of times across healthy, working projects quickly becomes noise. When a pattern appears frequently across a repository, aggregate related issues or provide sensible thresholds so the output stays helpful and focused.
Match severity to impact:
- ERROR: The tool will fail to load or run the file, or the issue presents a real security risk.
- WARNING: The file functions, but quality or maintainability is noticeably degraded.
- INFO: Gentle suggestions, style guidance, or advisory review prompts.
Always respect the configured severity: never hardcode severity= on the primary violation, so user overrides in .skillsaw.yaml work seamlessly.
Keep findings concise and consolidated:
- One defect, one finding: Group related issues together. For example, report invalid list items or a folder of unreferenced files as a single consolidated finding with examples.
- Recognize common placeholders: Understand standard templates and sample strings (such as
${VERSION}, dummy auth tokens, or example endpoints) so helpful documentation isn't flagged as invalid. - Clear, actionable messages: State what happened and how to fix it. Suggest rule configuration options (like exclusions or custom thresholds) before suggesting a disable comment. Focus checks on what files actually do rather than just where they are placed.
Line numbers and the parse tree
- Always report line numbers on every violation traceable to a specific line, except whole-file violations.
- Use
read_yaml_commented()(fromutils.py) for YAML — never useyaml.safe_load()orread_yaml(). Keep line numbers via the ruamel.yaml objectsread_yaml_commented()returns. - Use
commented_key_line(node, key)/commented_item_line(node, index)for 1-based line numbers from ruamel structures. - Use
read_toml()(fromutils.py) for TOML. It returns(data, error). - Never fabricate line numbers — if a field is missing, omit the line.
- Declare
repo_typesto control whenenabled: autofires. - Declare
config_schemawhen the rule accepts parameters. - Always keep EVERYTHING part of the parse tree.
- Keep the prose/config split in the block hierarchy —
ContentBlocksubclasses are prose for an agent's context window and get every content-quality rule automatically, so never put a config file under one: content rules would lint it as instruction text. Structured JSON (settings, hooks, MCP) subclassesJsonConfigBlock; structured YAML or TOML gets a directLintTargetsubclass, since that hierarchy is JSON-specific —OpenAIMetadataBlockandGrokConfigBlockare the patterns. One exception,CodexConfigHooksBlock: a hooks file in another syntax stays aHooksBlock— the hooks rules iterate that hierarchy and no hooks role mixin exists — and declares its syntax on the block (syntax_name,mapping_noun) so no rule branches on it.
Never require line numbers for JSON or TOML: neither parser gives a position, so both report at file level.
Autofix invariants
- Avoid negative lookarounds around backtrackable quantifiers in patterns
that feed autofixes. A trailing
(?!\))after\.\w{1,10}never rejects the match — the engine backtracks into a truncated match and the fix splices a corrupted span (issue #321). Match greedily, then check the characters adjacent to the full match in code and reject there. - Fixes that add a frontmatter field must guard against the key already
existing —
check()may report "missing" for an empty/null value while thekey:line is still present. Usereplace_frontmatter_field()first and fall back toprepend_frontmatter_fields(), or the fix prepends a duplicate key on every run and never converges (issue #321). - Add new SAFE-autofix edge cases to the
tests/fixtures/autofix/safe-idempotencyfixture soTestSafeAutofixIdempotencyguards them; updateEXPECTED_SAFE_VIOLATIONSwhen the fixture grows. - Autofix stands down entirely for plugins installed under
.codex/plugins/— vendor-managed content is diagnostic-only, so every rule'sfix()silently no-ops there, by design. A new rule's fix needs no guard of its own for that case, and a "fix didn't apply" report from such a path is expected behavior, not a bug.
Ecosystem provenance
"Which ecosystem owns this plugin directory" is decided in exactly one
place: RepositoryContext.provenance(), which returns a cached
PluginProvenance record per directory. It lives on
RepositoryProvenanceMixin in repository_provenance.py, alongside
in_format_scope, is_codex_only_plugin, and the containment helpers;
RepositoryContext mixes that in, so every caller still reaches it as
RepositoryContext.provenance(). Never answer an ownership
question with a fresh filesystem probe in a rule or in the tree builder —
two call sites probing independently is how a directory falls between
per-ecosystem attach paths and loses its content silently.
- Discovery is state-free and lives in
src/skillsaw/discovery/(claude.py,codex.py,agent_plugins.py,detect.py, andexcludes.py): functions take a root path and callbacks and return data, holding no caches and importing nothing fromcontext.RepositoryContextis the stateful orchestrator — its methods wrap the discovery functions with the per-context caches and render the provenance verdicts over the evidence they gather. - Evidence is filesystem-first and
--type-invariant. An override changes what discovery walks, not what the author declared, so provenance reads markers, contained manifests, and catalog files directly (_codex_catalog_files()), never discovery output. - The lint tree builds plugins in ONE pass over the union of claimed
directories. Each directory gets one container; prose (
commands/,agents/,rules/, README) attaches once with containment; config files attach per claiming ecosystem through the contained helpers. Never add a second per-ecosystem plugin loop. - Format rules gate on provenance declaratively, never with inline
guards. A rule that enforces one ecosystem's conventions declares
provenance_scope = "claude"on its class and iterates targets throughself.scoped_find(context, NodeType); the filtering happens in exactly one place (RepositoryContext.in_format_scope, reading each node'sprovenance_dir()), so a Codex-only directory is exempt while dual-manifest and unclaimed directories keep every check.TestProvenanceScopeMechanismpins which rules declare the scope. The mechanism fails open by node type:LintTarget.provenance_dir()returnsNoneunless the node type overrides it. Grepdef provenance_dirfor the current set — todayPluginNode,AgentPluginNode, andAgentPluginConfigNodeinlint_target.py, plusCommandBlockandAgentBlockin the frontmatter blocks module. A scope-declaring rule iterating a node type without the override silently gets no filtering, so add the override first. provenance_scopeis for rules iterating SHARED node types. A rule readingCommandBlockorPluginNode— types every ecosystem populates — needs the declaration to stay out of another ecosystem's directories. A rule iterating an ecosystem-exclusive node type (AgentPluginConfigNode,CodexPluginConfigNode) is already scoped by its target, and declaringprovenance_scopethere is a bug: under a forced--typewith no filesystem claim the scope filter skips the node, so the rule never reports a missing or malformed manifest. That is whyagent-plugin-json-validandagent-plugin-mcp-validuse plainfind(AgentPluginConfigNode).- Conditional strictness is not a skip. The ecosystem-tightened MCP
shape checks stay
provenance_scope = Noneand gate the tightened checks alone oncontext.in_codex_only_plugin(path)— the one spelling of "does a Codex-exclusive plugin own this file" — so dual-manifest plugins keep their established Claude results (TestDualManifestBackwardCompatpins this). - Hooks get one subclass per host instead of conditional strictness.
Each host's hooks file is its own
HooksBlocksubclass — Claude, Codex, Muse, Cursor, Grok, Antigravity — chosen from provenance inbuild_lint_tree, with a<host>-hooks-validrule iterating it.hooks-dangerousandhooks-prohibitedread the sharedHooksBlockbase, so a new host needs no changes there. Exception:GrokPluginHooksBlockhas no shape rule — another adapter loads a Grok plugin's hooks file, and 1.0.13 shows no observable.
Ecosystems and editor tools are different problems. An ecosystem
packages and installs content (Claude plugins, Codex, Grok Build, Agent
Plugins, Antigravity), so it needs provenance: two can claim one directory,
and the format rules must stay out of each other's trees. An editor tool (Cursor,
Copilot, Cline, Qwen) reads its own configuration locations, which no other
tool claims, so it needs no provenance machinery at all. Both are
RepositoryType members — detection produces one set, and that enum is the
only vocabulary. Pick the recipe that matches; following the ecosystem one
for an editor tool builds machinery that design does not need.
Adding an editor tool (Cursor is the worked example): add its directory
name to AGENT_TOOL_DIR_NAMES in the detect.py discovery module if it reads a
directory rather than a single root file, so one walk finds it anywhere in
the tree; add its skill directory to CONVENTIONAL_SKILL_DIRS in the
discovery package, plus NESTED_TOOL_SKILL_DIRS in detect.py when a
package can carry its own <dir>/skills/ — CONVENTIONAL_SKILL_DIRS is
root-anchored and reaches only the repository root's copy; add a
RepositoryType member in repository_types.py,
list it in TOOL_REPO_TYPES, give it a slot in
RepositoryContext._TYPE_PRIORITY (context.py) or the JSON and SARIF
reports' repo_type falls through to unknown; key its evidence by that member's value in
_TOOL_EVIDENCE (or a marker() check) in tool_types() — detection
must agree with attachment, or the lint tree grows blocks no gated rule
ever looks at; add block classes whose category encodes the budget role (command
for on-demand prompts, instruction for always-on prose, agent for
subagents); attach them in the editor-directory loop in build_lint_tree.
Structural rules for the tool declare
repo_types = frozenset({RepositoryType.<TOOL>}) and iterate their own
block type — never provenance_scope, which is for shared node types only.
A tool that routes agents or commands by description also needs its type in
content-description-routing's repo_types and those block types in that
rule's traversal list — it walks an explicit list, so attaching is not
enough.
Prefer subclassing a shared block
(VsCodeMcpBlock(McpBlock)) over editing existing rule files, so the
security rules pick the tool up without a visit.
Four more, from OpenCode (formats/opencode.py, rules/builtin/opencode/):
check APM_COMPILED_DIR_TARGETS for a tool whose directory is also a
compile target (.opencode is, resolved on APM's evidence alone); set
jsonc: ClassVar[bool] = True on a JSONC host's block, never a
Claude-family one; keep configuration vocabulary — key sets, alias tables —
in formats/<tool>.py, not restated in rules; and when a dialect diverges
enough that a shared rule's every check misfires, subclassing isn't
enough — defer to the tool's own, as mcp-valid-json does for Agent
Plugins, OpenCode and Grok. Declare that deferral on the block, never as a
branch in the rule: McpShapeDeferral carries the gating repository type,
whether the dialect-neutral checks survive, and which rule owns the syntax
failure.
Adding an ecosystem (Codex and Agent Plugins are the worked examples):
put its discovery leg — the state-free plugin/manifest walks, catalog
enumeration, local-source resolution and install-location helpers — in a new
src/skillsaw/discovery/<ecosystem>.py; add its evidence probe to
provenance() in repository_provenance.py, and its
context wrappers (caching, --type gating) in context.py, or a
repository_<ecosystem>.py mixin when its line cap bites (Grok's);
a RepositoryType member per packaging claim, in SKILL_REPO_TYPES when
its plugins carry skills — which turns the agentskill-* rules on but
discovers none until discover_skills and context._discover_skills gain
a leg — and in _TYPE_PRIORITY, or repo_type reads unknown; its marker in
PLUGIN_MARKER_DIR_NAMES (discovery/detect.py), so one walk finds a
package's plugin or catalog, never a second traversal; its config-file
cluster in the single plugin pass in build_lint_tree (through a contained
helper);
teach in_format_scope nothing — it already reads the
provenance claim set, so every provenance_scope-declaring rule honors
the new claim the moment the evidence probe lands; give the new ecosystem's
format rules provenance_scope = "<ecosystem>" only where they iterate a
shared node type — rules that iterate the ecosystem's own node type gate by
node type instead (see the scope rule above);
extend the union in merge_plugin_dirs callers if it discovers plugin
directories of its own. Prose needs no work — every claimed directory
already gets it, and no existing rule needs a scope visit: scope is
declared per rule class. A rule keying on an explicit repo_types list is
the exception.