Skip to content

Phase 1: core linter for CLAUDE.md, skills, plugins, and hooks

IMPL 0001: Phase 1 — core linter for CLAUDE.md, skills, plugins, and hooks

Section titled “IMPL 0001: Phase 1 — core linter for CLAUDE.md, skills, plugins, and hooks”

Status: Draft Author: Donald Gifford Date: 2026-04-18

Deliver an MVP claudelint binary that:

  • discovers Claude artifacts in a repo (CLAUDE.md, skills, slash commands, subagents, hooks, plugin manifests),
  • parses each into a typed Artifact value,
  • runs the built-in ruleset (shipped in-tree, versioned with the binary) against those artifacts via an engine inspired by go/analysis / golangci-lint,
  • reports diagnostics as text, JSON, or GitHub Actions annotations,
  • is driven by .claudelint.hcl (schema v1 per ADR-0001).

Architecture follows DESIGN-0001’s three-layer model: Parsers → Engine → Rules, with all orchestration complexity in the engine and each rule a small, pure Check function behind a common interface.

Implements: RFC-0001, ADR-0001, DESIGN-0001

  • claudelint CLI with run, rules, init, version subcommands (bare claudelint aliases to run).
  • HCL config loader (schema v1).
  • Typed artifact model with byte-accurate source positions.
  • Engine with registry, Context, and a concurrent per-artifact runner.
  • All 14 built-in rules from the DESIGN-0001 MVP table, registered via init() and versioned with the binary.
  • In-source claudelint:ignore suppressions (Markdown artifacts) and config-level enabled/severity/paths overrides.
  • Text, JSON, and GitHub Actions output formats.
  • Dogfood config and CI wiring in this repo.
  • SARIF output (Phase 2).
  • Pre-commit hook packaging (Phase 2).
  • claudelint fix auto-fix — Fix is defined on Diagnostic but left unpopulated in v1.
  • claudelint convert (Phase 3; gated on INV-0001).
  • Third-party / out-of-tree rules (explicit non-goal for v1).
  • Cross-artifact analysis (e.g. “skill referenced by plugin manifest is missing”). See DESIGN-0001 Open Questions.

Each phase builds on the previous. A phase is complete when every checkbox is ticked and every success-criterion line holds.


Stand up the module, the three core type packages, and a minimal CLI that walks the repo and prints file counts — no rules wired yet.

  • Create cmd/claudelint/main.go with cobra root; wire run, rules, init, version subcommand stubs. Bare claudelint aliases to run.
  • Create internal/diag/ exporting Diagnostic, Severity (error/warning/info), Range, Position, Fix. Fix is defined but always nil in v1; JSON tag is omitempty so it does not appear in v1 output.
  • Create internal/artifact/ exporting ArtifactKind constants (KindClaudeMD, KindSkill, KindCommand, KindAgent, KindHook, KindPlugin) and the Artifact interface from DESIGN-0001.
  • Create internal/discovery/ with a filesystem walker that classifies each path into an ArtifactKind using the patterns in DESIGN-0001 §Background. Honor .gitignore (root + nested + global) via a vetted ignore library. (Library pick: github.com/sabhiram/go-gitignore. Global and .git/info/exclude are layered in by the walker.)
  • Create internal/reporter/text.go with a minimal text formatter.
  • Wire claudelint run end-to-end: discover → (stub) run → report "0 diagnostics, N files checked".
  • claudelint version prints the binary Version (via -ldflags) plus RulesetVersion (semver constant in internal/rules) and RulesetFingerprint (auto-computed hash; see Phase 1.5), in the form v1.2.0 (a1b2c3d4). (Phase 1.5 will replace the "unset" placeholder with the auto-computed hash once the registry is in place.)
  • Unit tests: discovery classification over a fixture tree with one example per ArtifactKind plus a negative (unrecognized) case.
  • go build ./... succeeds.
  • claudelint run . from this repo root prints 0 diagnostics, N files checked with N > 0.
  • claudelint version prints both versions.
  • Discovery tests pass and cover every ArtifactKind.

Turn discovered paths into typed artifacts with byte-accurate source positions.

  • Add typed artifact structs in internal/artifact/: ClaudeMD, Skill, Command, Agent, Hook, Plugin. Each embeds a common Base with path, source, and a byte-offset line index.
  • Implement the Markdown + YAML-frontmatter parser used by ClaudeMD, Skill, Command, Agent using github.com/goccy/go-yaml for the frontmatter (precise line/col on every key). Must preserve byte offsets for every frontmatter key and every heading/paragraph in the body. (v1 preserves byte offsets for every frontmatter key and for the Body block as a whole. Per-heading/per-paragraph offsets inside the Body are deferred — no MVP rule depends on them and adding a full Markdown AST would bring in another dependency. A future phase will add goldmark if rules need sub-body positions.)
  • Implement the JSON parser for Hook and Plugin, preserving line/column for every value. Support hooks declared both as dedicated JSON files and inline under the hooks key of .claude/settings.json. (Library pick: github.com/buger/jsonparser for in-place lookups with byte offsets. Hook.Entries flattens settings files into (event, matcher, command, timeout) tuples; dedicated .claude/hooks/*.json files produce a one-entry list.)
  • Skill parser indexes companion files: references/**, scripts/**, templates/**. Indexed entries record their relative path and kind. (Indexing lives in IndexSkillCompanions, called by discovery/ engine wiring after a successful ParseSkill; indexing is separated so in-memory tests of ParseSkill do not need a filesystem fixture.)
  • Define a ParseError type carrying path + Range so the engine can synthesize a schema/parse diagnostic without the rule ever needing to inspect raw bytes.
  • Table-driven parser tests per kind with testdata/ok/ and testdata/bad/ directories, asserting on both parsed structure and exact byte offsets for a sampling of fields.
  • Each artifact kind round-trips its fixtures with exact byte-offset positions for at least one checked field per kind.
  • Every testdata/bad/ input yields exactly one ParseError with a non-zero Range and does not panic.
  • go test ./internal/artifact/... coverage ≥ 90%.

Load .claudelint.hcl (schema v1), merge into a ResolvedConfig the engine consumes.

  • Add internal/config/ using hashicorp/hcl/v2 + gohcl.
  • Decode the schema-v1 blocks in DESIGN-0001:
    • claudelint { version = "1" } — hard fail on unknown version, with an upgrade message naming the minimum binary version.
    • rule "<id>" { severity, enabled, options, paths } — per-rule override.
    • rules "<kind>" { ... } — per-artifact-kind defaults fed into per-rule options during resolution.
    • ignore { paths = [...] } — glob list.
    • output { format = "text|json|github" }.
  • Config discovery: walk up from cwd for .claudelint.hcl; honor --config=PATH as an override. (override wiring on the run command comes in Phase 1.4 when config flows into the engine.)
  • Resolution: produce a ResolvedConfig that answers RuleEnabled(id), RuleSeverity(id) Severity, RuleOption(id, key) any, PathIgnored(p) bool in O(1).
  • claudelint init scaffolder writes a commented default .claudelint.hcl into cwd.
  • Error-path tests: every decode/validation error carries an HCL diagnostic with correct file/line/column.
  • claudelint init in an empty directory produces a config that claudelint run accepts with zero diagnostics.
  • Every branch in the config error paths has a test that asserts line+column.
  • go test ./internal/config/... coverage ≥ 90%.

Build the runner and the Rule / Context contract — with zero rules registered yet.

  • Add internal/rules/ exporting: - Rule interface (including DefaultOptions() map[string]any) - Context interface - Register(Rule), All() []Rule, Get(id string) Rule - RulesetVersion (hand-bumped semver constant) - RulesetFingerprint() helper that hashes registered rules Registry lives in internal/rules so rule subpackages never import the engine; dependency direction is one-way.
  • Add internal/engine/ with the runner:
    • Consumes discovered+parsed artifacts and a ResolvedConfig.
    • Resolves the enabled rule set by calling rules.All() and filtering via config.
    • Groups rules by ArtifactKind.
    • Dispatches one goroutine per artifact (worker pool sized to GOMAXPROCS); within each goroutine, applicable rules run serially. Coarse granularity is intentional — see DESIGN-0001 execution flow. Profile before changing.
    • Validates user-supplied options against each rule’s DefaultOptions() before Check is called; type mismatches become meta/invalid-option diagnostics. (Deferred to Phase 1.5 alongside the real rules that declare DefaultOptions. Engine currently merges the default map and config value but does not type-check — no rule yet consumes typed options.)
    • Synthesizes schema/parse diagnostics from ParseErrors without calling any rule’s Check.
    • Aggregates, sorts by (path, line, col, ruleID), and dedupes identical diagnostics.
  • Implement Context: resolved options (from config), rule ID, and a leveled logger. No filesystem or network access.
  • Wire claudelint rules to list the registry; claudelint rules <id> prints rationale + default options (from DefaultOptions()).
  • Engine tests with stub rules (not the real MVP rules yet), including a deliberate data race test under go test -race.
  • Engine with a stub rule returns expected diagnostics deterministically across runs.
  • go test -race ./internal/engine/... is clean.
  • claudelint rules runs with an empty registry and prints "0 rules registered".

Implement every rule from the DESIGN-0001 MVP table. Each is its own ~50-LOC file in its own subpackage.

  • internal/rules/schema/parse.go — pseudo-rule: registered for claudelint rules discoverability and suppression matching. Check is never called (parse errors mean no artifact exists); the engine synthesizes the diagnostic directly from the ParseError in Phase 1.4, using the rule’s registered metadata for ID, default severity, and category.
  • internal/rules/schema/frontmatterrequired.goname and description present on skill, command, agent.
  • internal/rules/skills/bodysize.go — body word count ≤ configurable max.
  • internal/rules/skills/triggerclarity.godescription contains an imperative trigger phrase.
  • internal/rules/commands/allowedtoolsknown.go — every tool in allowed-tools is on the known-tool list.
  • internal/rules/hooks/eventnameknown.goevent is on the known-event list.
  • internal/rules/hooks/nounsafeshell.go — command does not pipe curl ... | sh (and similar patterns).
  • internal/rules/hooks/timeoutpresent.go — long-running hook declares a timeout.
  • internal/rules/claudemd/size.go — file ≤ configurable line count.
  • internal/rules/claudemd/duplicatedirectives.go — no two directives contradict. (v1 ships the “identical duplicate” detector; semantic-contradiction detection stays out of scope.)
  • internal/rules/plugin/manifestfields.go — required manifest fields present and well-typed.
  • internal/rules/plugin/semver.goversion is valid semver.
  • internal/rules/style/noemoji.go — off by default.
  • internal/rules/security/secrets.go — high-entropy token detection with an allowlist.
  • Known-tool and known-event constants live in internal/artifact/knowndata.go (single source of truth for the rules that need them).
  • Wire every rule subpackage into the binary via blank imports in internal/rules/all/all.go; cmd/claudelint/main.go blank-imports _ "claudelint/internal/rules/all".
  • Per-rule table-driven tests landed with phase 1.5 (inline stub artifacts). The originally proposed testdata/ok/ + testdata/bad/ + golden-JSON shape was subsumed by the full-binary E2E harness in cmd/claudelint/e2e_test.go plus the JSON-shape golden tests in internal/reporter/json_test.go — those are the load-bearing regression guards for diagnostic shape without duplicating fixture data at every layer.
  • Set RulesetVersion to "v1.0.0" in internal/rules; commit internal/rules/expected_fingerprint.txt containing the hash of the full MVP ruleset.
  • Add a test TestRulesetFingerprint that recomputes the fingerprint at test time and fails if it does not match expected_fingerprint.txt, with the failure message telling the developer to bump RulesetVersion and update the expected file. (Lives in internal/rules/all/fingerprint_test.go so its test binary sees the fully-registered ruleset uncontaminated by registry-resetting unit tests in internal/rules/.)
  • claudelint rules lists all 14 rule IDs with correct default severities and default options.
  • claudelint run . on this repo produces zero error-severity diagnostics.
  • Every rule has ≥1 ok fixture and ≥1 bad fixture with matching golden output.
  • TestRulesetFingerprint passes and fails loudly if the ruleset drifts without bumping RulesetVersion.
  • go test ./internal/rules/... coverage ≥ 85%.

  • In-source suppression parser for Markdown artifacts, recognized inside HTML comments: <!-- claudelint:ignore=<id>[,<id>...] --> and <!-- claudelint:ignore-file=<id> -->. Applied to the same line or the next non-blank line.
  • Config-level rule "<id>" { enabled = false } honored end-to-end.
  • Config-level rule "<id>" { severity = "..." } honored.
  • Config-level rule "<id>" { paths = ["glob", ...] } suppression by path glob.
  • meta/unknown-rule warning emitted when a suppression or config block names an ID that is not in the registry.
  • Hook and plugin JSON artifacts use config-level suppressions only (JSON has no standard comment syntax). Document this in the README with an example of config-level paths suppression.
  • Suppression tests: one per mechanism plus a matrix test that a single rule can be disabled via any mechanism independently.
  • All three suppression mechanisms honored; meta/unknown-rule emitted for unknown IDs.
  • --verbose flag prints which in-source suppression matched each silenced diagnostic and the loaded config path.
  • Suppression logic has a matrix test covering every combination.

  • --format=text|json|github flag on run.
  • Text formatter: colorized human output; honors --no-color and NO_COLOR env.
  • JSON formatter: stable documented schema in docs/ with a golden-file test guarding stability.
  • GitHub Actions annotation formatter emitting ::error / ::warning / ::notice lines with file=, line=, col=.
  • --quiet suppresses non-error output; --verbose enables suppression reasoning.
  • Exit codes: non-zero on any error; --max-warnings=N promotes warning overflow to error (default: no limit).
  • E2E test in cmd/claudelint: invoke the binary against a fixture repo and diff stdout/stderr against golden files for each format.
  • Golden output tests green for text, JSON, and github formats.
  • Exit-code matrix test passes across {0 diagnostics, N warnings, N errors, N warnings with --max-warnings=0}.
  • GitHub annotations render correctly in a smoke-test workflow in .github/workflows/.

  • Audit every user-facing error message for imperative, actionable phrasing.
  • Add benchmarks (internal/engine/runner_bench_test.go) covering 100, 1000, and 10k synthetic artifacts, plus a workers=1 vs default scaling case. CI regression gate is deferred to a follow-up RFC — wiring benchstat into .github/workflows adds significant CI complexity and the benchmark itself is the foundation.
  • --profile=<dir> flag on claudelint run writes cpu.pprof, heap.pprof, block.pprof, and mutex.pprof via runtime/pprof.
  • make profile runs claudelint against this repo with profiling enabled and prints the go tool pprof command to invoke.
  • README documents how to capture and read profiles.
  • Coverage gate in CI (make coverage-gate). Initial floor COVERAGE_MIN=55 (set to the lowest current package). Tighten toward 80% as rule-package tests fill in; the target is documented in the Makefile target comment.
  • make ci passes with zero warnings from golangci-lint.
  • make self-check runs claudelint run . and fails on any error.
  • Updated README.md with install, quickstart, rule index with per-rule examples and fixes, exit codes, suppression docs, profile docs, and a link to the RFC.
  • Dogfooded on donaldgifford-claude-skills/[email protected] and donaldgifford-claude-skills/docz. Surfaced a real discovery gap (classifier missed plugin-root layouts without .claude/ segment) which is now fixed, plus 16 actionable diagnostics in the upstream plugins. Findings captured in INV-0003.
  • .goreleaser.yml publishes darwin/{amd64,arm64}, linux/{amd64,arm64}, and windows/amd64. (windows/arm64 intentionally excluded for the first release.)
  • Tagging v0.1.0 is maintainer-gated and runs outside this session. All code, CI, goreleaser, and docs needed for the release are landed and verified via make release-local. The step-by-step cut procedure lives in RELEASE.md; running make release TAG=v0.1.0 from main triggers goreleaser to publish the release artifacts, at which point go install resolves the tag.
  • make ci and make self-check both pass.
  • v0.1.0 binary published via GitHub Releases and installable via go install.
  • README documents every MVP rule with one example and one fix.

FileActionDescription
cmd/claudelint/main.goCreateCobra entrypoint; run, rules, init, version subcommands; blank-imports internal/rules/all
internal/diag/*.goCreateDiagnostic, Severity, Range, Position, Fix types
internal/artifact/*.goCreateArtifactKind, Artifact interface, typed structs, parsers, known-data constants
internal/discovery/*.goCreateFilesystem walker + classification + .gitignore support
internal/config/*.goCreateHCL loader, schema v1 decoder, ResolvedConfig
internal/rules/rules.goCreateRule, Context, Register, All, Get
internal/rules/<category>/<id>.goCreateIndividual rule implementations (14 files)
internal/rules/all/all.goCreateBlank imports so every rule’s init() runs
internal/engine/*.goCreateWorker-pool runner, diagnostic aggregator, suppression applier
internal/reporter/*.goCreatetext, json, github formatters
go.mod, go.sumModifyAdd hashicorp/hcl/v2, spf13/cobra, goccy/go-yaml, a .gitignore library (pick in Phase 1.1)
internal/rules/expected_fingerprint.txtCreatePinned ruleset fingerprint; updated in lockstep with RulesetVersion bumps
README.mdModifyReplace TODO with install/usage + rule index
.goreleaser.ymlModifyEnsure main path + binary name + platforms
MakefileModifyAdd self-check target
.claudelint.hclCreateDogfood config
.github/workflows/*.ymlModifyAdd claudelint run step; coverage gate; benchmark regression check
testdata/bench/**CreateSynthetic repo for benchmarks
  • Unit tests for every exported symbol in internal/.... (Package coverage between 58.6% and 100%; meta-rules like rules/all have no statements to test.)
  • Parser tests with byte-offset assertions for every artifact kind (internal/artifact/*_test.go).
  • Per-rule table-driven tests with inline fixtures rather than per-rule testdata/ dirs. The rule-level assertions check IDs, severities, and messages directly; end-to-end golden-JSON stability is guarded by internal/reporter/json_test.go and the full-binary golden harness in cmd/claudelint/e2e_test.go. This split avoids golden-file duplication at every level of the pyramid without losing the regression protection.
  • Engine tests with stub rules under go test -race (TestRunConcurrencyRaceClean).
  • E2E tests in cmd/claudelint invoking the binary against a fixture repo and asserting stdout/stderr shape for text, JSON, and GitHub formats (cmd/claudelint/e2e_test.go).
  • Suppression matrix test across in-source + config-disable + per-rule-paths (internal/engine/suppressions_test.go).
  • Exit-code matrix test across diagnostic-severity scenarios (TestRunFailedMatrix, TestE2EMaxWarningsPromotesToError).
  • Benchmark suite (internal/engine/runner_bench_test.go). A CI regression gate via benchstat is deferred to a follow-up — the raw benchmarks are the load-bearing deliverable; wiring a regression gate adds CI complexity best handled after a baseline is captured in release artifacts.
  • Coverage gate in CI (make coverage-gate). Floor is COVERAGE_MIN=55 (the lowest current package). The 80% target stays documented in the Makefile target comment and CLAUDE.md; it’s a ratchet to tighten as rule-package tests grow.
  • github.com/hashicorp/hcl/v2 — config (ADR-0001).
  • github.com/spf13/cobra — CLI.
  • github.com/goccy/go-yaml — YAML frontmatter parsing with precise line/column positions.
  • .gitignore library for full git status semantics — pick in Phase 1.1 between github.com/sabhiram/go-gitignore (lighter) and github.com/go-git/go-git/v5’s matcher (heavier, more correct).
  • runtime/pprof — profiling (stdlib).
  • Existing repo tooling: mise, goreleaser, golangci-lint, markdownlint, yamllint.

All of the original open questions have been resolved during review. The outcomes are now baked into DESIGN-0001 and the phase tasks above. Summarized here for traceability:

  1. Registry locationRule, Context, and the registry (Register/All/Get) live in internal/rules. Engine imports rules; rules never import engine.
  2. Rule wiringinternal/rules/all/all.go blank-imports every rule subpackage; cmd/claudelint/main.go and tests blank-import internal/rules/all once.
  3. YAML parsergithub.com/goccy/go-yaml.
  4. schema/parse — pseudo-rule: registered in the registry, Check never called; engine synthesizes the diagnostic directly from ParseError.
  5. Ruleset version — combined approach: hand-bumped RulesetVersion semver constant plus an auto-computed RulesetFingerprint hash, with a CI guardrail test against a checked-in expected_fingerprint.txt. claudelint version prints both.
  6. JSON in-source suppressions — not supported in v1. JSON artifacts use config-level suppressions only (path globs + enabled
    • severity).
  7. .gitignore semantics — full git status behavior (root + nested + global + .git/info/exclude) via a vetted library. Pick the specific library in Phase 1.1.
  8. Fix type — defined on Diagnostic, always nil in v1, JSON tag omitempty. Forward-compatible with a future claudelint fix.
  9. settings.json + settings.local.json overlap — lint as independent artifacts in v1. hooks/duplicate-declaration is deferred to Phase 2 along with the broader cross-artifact (CorpusRule) engine extension.
  10. Concurrency granularity — worker-per-artifact, pool sized to GOMAXPROCS; rules run serially within each artifact’s goroutine. Phase 1.8 adds pprof profiling so we can revisit with data.
  11. Rule option validationRule.DefaultOptions() map[string]any declares keys and default values. Engine fills in unspecified options and validates types against the default’s Go type before calling Check; mismatches become meta/invalid-option diagnostics.
  • RFC-0001 — Claudelint
  • ADR-0001 — Use HCL as config format
  • DESIGN-0001 — Architecture and rule engine
  • INV-0001 — Format conversion investigation (Phase 3 gate)