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
- Objective
- Scope
- Implementation Phases
- File Changes
- Testing Plan
- Dependencies
- Resolved Decisions
- References
Objective
Section titled “Objective”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
Artifactvalue, - 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
In Scope
Section titled “In Scope”claudelintCLI withrun,rules,init,versionsubcommands (bareclaudelintaliases torun).- 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:ignoresuppressions (Markdown artifacts) and config-levelenabled/severity/pathsoverrides. - Text, JSON, and GitHub Actions output formats.
- Dogfood config and CI wiring in this repo.
Out of Scope
Section titled “Out of Scope”- SARIF output (Phase 2).
- Pre-commit hook packaging (Phase 2).
claudelint fixauto-fix —Fixis defined onDiagnosticbut 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.
Implementation Phases
Section titled “Implementation Phases”Each phase builds on the previous. A phase is complete when every checkbox is ticked and every success-criterion line holds.
Phase 1.1: Foundation & CLI skeleton
Section titled “Phase 1.1: Foundation & CLI skeleton”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.gowith cobra root; wirerun,rules,init,versionsubcommand stubs. Bareclaudelintaliases torun. - Create
internal/diag/exportingDiagnostic,Severity(error/warning/info),Range,Position,Fix.Fixis defined but always nil in v1; JSON tag isomitemptyso it does not appear in v1 output. - Create
internal/artifact/exportingArtifactKindconstants (KindClaudeMD,KindSkill,KindCommand,KindAgent,KindHook,KindPlugin) and theArtifactinterface from DESIGN-0001. - Create
internal/discovery/with a filesystem walker that classifies each path into anArtifactKindusing 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/excludeare layered in by the walker.) - Create
internal/reporter/text.gowith a minimal text formatter. - Wire
claudelint runend-to-end: discover → (stub) run → report"0 diagnostics, N files checked". -
claudelint versionprints the binaryVersion(via-ldflags) plusRulesetVersion(semver constant ininternal/rules) andRulesetFingerprint(auto-computed hash; see Phase 1.5), in the formv1.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
ArtifactKindplus a negative (unrecognized) case.
Success Criteria
Section titled “Success Criteria”go build ./...succeeds.claudelint run .from this repo root prints0 diagnostics, N files checkedwithN > 0.claudelint versionprints both versions.- Discovery tests pass and cover every
ArtifactKind.
Phase 1.2: Parsers and artifact model
Section titled “Phase 1.2: Parsers and artifact model”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 commonBasewithpath,source, and a byte-offset line index. - Implement the Markdown + YAML-frontmatter parser used by
ClaudeMD,Skill,Command,Agentusinggithub.com/goccy/go-yamlfor 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
HookandPlugin, preserving line/column for every value. Support hooks declared both as dedicated JSON files and inline under thehookskey of.claude/settings.json. (Library pick:github.com/buger/jsonparserfor in-place lookups with byte offsets.Hook.Entriesflattens settings files into (event, matcher, command, timeout) tuples; dedicated.claude/hooks/*.jsonfiles produce a one-entry list.) -
Skillparser indexes companion files:references/**,scripts/**,templates/**. Indexed entries record their relative path and kind. (Indexing lives inIndexSkillCompanions, called by discovery/ engine wiring after a successfulParseSkill; indexing is separated so in-memory tests ofParseSkilldo not need a filesystem fixture.) - Define a
ParseErrortype carrying path +Rangeso the engine can synthesize aschema/parsediagnostic without the rule ever needing to inspect raw bytes. - Table-driven parser tests per kind with
testdata/ok/andtestdata/bad/directories, asserting on both parsed structure and exact byte offsets for a sampling of fields.
Success Criteria
Section titled “Success Criteria”- 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 oneParseErrorwith a non-zeroRangeand does not panic. go test ./internal/artifact/...coverage ≥ 90%.
Phase 1.3: Config loader (HCL)
Section titled “Phase 1.3: Config loader (HCL)”Load .claudelint.hcl (schema v1), merge into a ResolvedConfig the
engine consumes.
- Add
internal/config/usinghashicorp/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=PATHas an override. (override wiring on theruncommand comes in Phase 1.4 when config flows into the engine.) - Resolution: produce a
ResolvedConfigthat answersRuleEnabled(id),RuleSeverity(id) Severity,RuleOption(id, key) any,PathIgnored(p) boolin O(1). -
claudelint initscaffolder writes a commented default.claudelint.hclinto cwd. - Error-path tests: every decode/validation error carries an HCL diagnostic with correct file/line/column.
Success Criteria
Section titled “Success Criteria”claudelint initin an empty directory produces a config thatclaudelint runaccepts with zero diagnostics.- Every branch in the config error paths has a test that asserts line+column.
go test ./internal/config/...coverage ≥ 90%.
Phase 1.4: Engine core
Section titled “Phase 1.4: Engine core”Build the runner and the Rule / Context contract — with zero rules
registered yet.
- Add
internal/rules/exporting: -Ruleinterface (includingDefaultOptions() map[string]any) -Contextinterface -Register(Rule),All() []Rule,Get(id string) Rule-RulesetVersion(hand-bumped semver constant) -RulesetFingerprint()helper that hashes registered rules Registry lives ininternal/rulesso 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()beforeCheckis called; type mismatches becomemeta/invalid-optiondiagnostics. (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/parsediagnostics fromParseErrors without calling any rule’sCheck. - Aggregates, sorts by
(path, line, col, ruleID), and dedupes identical diagnostics.
- Consumes discovered+parsed artifacts and a
- Implement
Context: resolved options (from config), rule ID, and a leveled logger. No filesystem or network access. - Wire
claudelint rulesto list the registry;claudelint rules <id>prints rationale + default options (fromDefaultOptions()). - Engine tests with stub rules (not the real MVP rules yet),
including a deliberate data race test under
go test -race.
Success Criteria
Section titled “Success Criteria”- Engine with a stub rule returns expected diagnostics deterministically across runs.
go test -race ./internal/engine/...is clean.claudelint rulesruns with an empty registry and prints"0 rules registered".
Phase 1.5: Built-in rules (MVP)
Section titled “Phase 1.5: Built-in rules (MVP)”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 forclaudelint rulesdiscoverability and suppression matching.Checkis never called (parse errors mean no artifact exists); the engine synthesizes the diagnostic directly from theParseErrorin Phase 1.4, using the rule’s registered metadata for ID, default severity, and category. -
internal/rules/schema/frontmatterrequired.go—nameanddescriptionpresent on skill, command, agent. -
internal/rules/skills/bodysize.go— body word count ≤ configurable max. -
internal/rules/skills/triggerclarity.go—descriptioncontains an imperative trigger phrase. -
internal/rules/commands/allowedtoolsknown.go— every tool inallowed-toolsis on the known-tool list. -
internal/rules/hooks/eventnameknown.go—eventis on the known-event list. -
internal/rules/hooks/nounsafeshell.go— command does not pipecurl ... | sh(and similar patterns). -
internal/rules/hooks/timeoutpresent.go— long-running hook declares atimeout. -
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.go—versionis 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.goblank-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 incmd/claudelint/e2e_test.goplus the JSON-shape golden tests ininternal/reporter/json_test.go— those are the load-bearing regression guards for diagnostic shape without duplicating fixture data at every layer. - Set
RulesetVersionto"v1.0.0"ininternal/rules; commitinternal/rules/expected_fingerprint.txtcontaining the hash of the full MVP ruleset. - Add a test
TestRulesetFingerprintthat recomputes the fingerprint at test time and fails if it does not matchexpected_fingerprint.txt, with the failure message telling the developer to bumpRulesetVersionand update the expected file. (Lives ininternal/rules/all/fingerprint_test.goso its test binary sees the fully-registered ruleset uncontaminated by registry-resetting unit tests ininternal/rules/.)
Success Criteria
Section titled “Success Criteria”claudelint ruleslists all 14 rule IDs with correct default severities and default options.claudelint run .on this repo produces zeroerror-severity diagnostics.- Every rule has ≥1 ok fixture and ≥1 bad fixture with matching golden output.
TestRulesetFingerprintpasses and fails loudly if the ruleset drifts without bumpingRulesetVersion.go test ./internal/rules/...coverage ≥ 85%.
Phase 1.6: Suppressions and filtering
Section titled “Phase 1.6: Suppressions and filtering”- 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-rulewarning 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
pathssuppression. - Suppression tests: one per mechanism plus a matrix test that a single rule can be disabled via any mechanism independently.
Success Criteria
Section titled “Success Criteria”- All three suppression mechanisms honored;
meta/unknown-ruleemitted for unknown IDs. -
--verboseflag prints which in-source suppression matched each silenced diagnostic and the loaded config path. - Suppression logic has a matrix test covering every combination.
Phase 1.7: Output formats and exit codes
Section titled “Phase 1.7: Output formats and exit codes”-
--format=text|json|githubflag onrun. - Text formatter: colorized human output; honors
--no-colorandNO_COLORenv. - JSON formatter: stable documented schema in
docs/with a golden-file test guarding stability. - GitHub Actions annotation formatter emitting
::error/::warning/::noticelines withfile=,line=,col=. -
--quietsuppresses non-error output;--verboseenables suppression reasoning. - Exit codes: non-zero on any
error;--max-warnings=Npromotes 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.
Success Criteria
Section titled “Success Criteria”- 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/.
Phase 1.8: Polish, docs, and release
Section titled “Phase 1.8: Polish, docs, and release”- 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 aworkers=1vs default scaling case. CI regression gate is deferred to a follow-up RFC — wiring benchstat into.github/workflowsadds significant CI complexity and the benchmark itself is the foundation. -
--profile=<dir>flag onclaudelint runwritescpu.pprof,heap.pprof,block.pprof, andmutex.pprofviaruntime/pprof. -
make profileruns claudelint against this repo with profiling enabled and prints thego tool pprofcommand to invoke. - README documents how to capture and read profiles.
- Coverage gate in CI (
make coverage-gate). Initial floorCOVERAGE_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 cipasses with zero warnings fromgolangci-lint. -
make self-checkrunsclaudelint run .and fails on any error. - Updated
README.mdwith 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]anddonaldgifford-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.ymlpublishes darwin/{amd64,arm64}, linux/{amd64,arm64}, and windows/amd64. (windows/arm64 intentionally excluded for the first release.) - Tagging
v0.1.0is maintainer-gated and runs outside this session. All code, CI, goreleaser, and docs needed for the release are landed and verified viamake release-local. The step-by-step cut procedure lives in RELEASE.md; runningmake release TAG=v0.1.0frommaintriggers goreleaser to publish the release artifacts, at which pointgo installresolves the tag.
Success Criteria
Section titled “Success Criteria”make ciandmake self-checkboth pass.v0.1.0binary published via GitHub Releases and installable viago install.- README documents every MVP rule with one example and one fix.
File Changes
Section titled “File Changes”| File | Action | Description |
|---|---|---|
cmd/claudelint/main.go | Create | Cobra entrypoint; run, rules, init, version subcommands; blank-imports internal/rules/all |
internal/diag/*.go | Create | Diagnostic, Severity, Range, Position, Fix types |
internal/artifact/*.go | Create | ArtifactKind, Artifact interface, typed structs, parsers, known-data constants |
internal/discovery/*.go | Create | Filesystem walker + classification + .gitignore support |
internal/config/*.go | Create | HCL loader, schema v1 decoder, ResolvedConfig |
internal/rules/rules.go | Create | Rule, Context, Register, All, Get |
internal/rules/<category>/<id>.go | Create | Individual rule implementations (14 files) |
internal/rules/all/all.go | Create | Blank imports so every rule’s init() runs |
internal/engine/*.go | Create | Worker-pool runner, diagnostic aggregator, suppression applier |
internal/reporter/*.go | Create | text, json, github formatters |
go.mod, go.sum | Modify | Add hashicorp/hcl/v2, spf13/cobra, goccy/go-yaml, a .gitignore library (pick in Phase 1.1) |
internal/rules/expected_fingerprint.txt | Create | Pinned ruleset fingerprint; updated in lockstep with RulesetVersion bumps |
README.md | Modify | Replace TODO with install/usage + rule index |
.goreleaser.yml | Modify | Ensure main path + binary name + platforms |
Makefile | Modify | Add self-check target |
.claudelint.hcl | Create | Dogfood config |
.github/workflows/*.yml | Modify | Add claudelint run step; coverage gate; benchmark regression check |
testdata/bench/** | Create | Synthetic repo for benchmarks |
Testing Plan
Section titled “Testing Plan”- Unit tests for every exported symbol in
internal/.... (Package coverage between 58.6% and 100%; meta-rules likerules/allhave 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 byinternal/reporter/json_test.goand the full-binary golden harness incmd/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/claudelintinvoking 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 isCOVERAGE_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.
Dependencies
Section titled “Dependencies”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..gitignorelibrary for fullgit statussemantics — pick in Phase 1.1 betweengithub.com/sabhiram/go-gitignore(lighter) andgithub.com/go-git/go-git/v5’s matcher (heavier, more correct).runtime/pprof— profiling (stdlib).- Existing repo tooling:
mise,goreleaser,golangci-lint,markdownlint,yamllint.
Resolved Decisions
Section titled “Resolved Decisions”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:
- Registry location —
Rule,Context, and the registry (Register/All/Get) live ininternal/rules. Engine imports rules; rules never import engine. - Rule wiring —
internal/rules/all/all.goblank-imports every rule subpackage;cmd/claudelint/main.goand tests blank-importinternal/rules/allonce. - YAML parser —
github.com/goccy/go-yaml. schema/parse— pseudo-rule: registered in the registry,Checknever called; engine synthesizes the diagnostic directly fromParseError.- Ruleset version — combined approach: hand-bumped
RulesetVersionsemver constant plus an auto-computedRulesetFingerprinthash, with a CI guardrail test against a checked-inexpected_fingerprint.txt.claudelint versionprints both. - JSON in-source suppressions — not supported in v1. JSON
artifacts use config-level suppressions only (path globs + enabled
- severity).
.gitignoresemantics — fullgit statusbehavior (root + nested + global +.git/info/exclude) via a vetted library. Pick the specific library in Phase 1.1.Fixtype — defined onDiagnostic, always nil in v1, JSON tagomitempty. Forward-compatible with a futureclaudelint fix.settings.json+settings.local.jsonoverlap — lint as independent artifacts in v1.hooks/duplicate-declarationis deferred to Phase 2 along with the broader cross-artifact (CorpusRule) engine extension.- 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. - Rule option validation —
Rule.DefaultOptions() map[string]anydeclares keys and default values. Engine fills in unspecified options and validates types against the default’s Go type before callingCheck; mismatches becomemeta/invalid-optiondiagnostics.
References
Section titled “References”- RFC-0001 — Claudelint
- ADR-0001 — Use HCL as config format
- DESIGN-0001 — Architecture and rule engine
- INV-0001 — Format conversion investigation (Phase 3 gate)