From 5f2cd3f118b5e4052babd0c24a0abd71c1efd042 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 09:39:41 +0200 Subject: [PATCH] docs(skill): ship SKILL.md and declare violation codes frozen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 4 of the roadmap — closes the "AI tools placing files incorrectly" use case with a real agent surface instead of a docs jungle. Spec: docs/specs/011-agent-skill.md. - skills/structlint/SKILL.md — agent-facing skill shipped in-repo at the root (not under .claude/, since it's a distributed artifact for consumers). Frontmatter with trigger phrases, when-to-run guidance, full decision table for the 13 violation codes (fix tree vs fix config), machine contracts (validate --format json shape, suggest JSON v1), the fix loop (suggest → patch/git mv → validate), setup recipes (hook install / pre-commit / GitHub Action / extends / schema modeline), and gotchas. - docs/user/violation-codes.md — canonical reference declaring the 13 codes frozen and append-only. Codes are a public contract; agents and CI parsers key on them, so they never rename or disappear. - .structlint.yaml gains skills/** in allowedPaths (self-validation lockstep — roadmap risk 5). - README.md gains a "Documentation" row for the codes doc and a dedicated "For AI agents" section pointing to SKILL.md. - docs/AI/overview.md gains a preamble routing agents to SKILL.md. Tests (test/skill_contract_test.go): - Every code in the CodeDescriptions registry has an entry in the codes doc AND in the SKILL.md decision table (both directions enforced — new codes without docs and stale docs both break CI). - SKILL.md starts with a frontmatter block declaring name + description. - Self-validation passes with the new skills/ directory (proves the .structlint.yaml update landed alongside). --- .structlint.yaml | 1 + README.md | 8 ++ docs/AI/overview.md | 2 + docs/user/violation-codes.md | 74 ++++++++++++++++ skills/structlint/SKILL.md | 159 +++++++++++++++++++++++++++++++++++ test/skill_contract_test.go | 107 +++++++++++++++++++++++ 6 files changed, 351 insertions(+) create mode 100644 docs/user/violation-codes.md create mode 100644 skills/structlint/SKILL.md create mode 100644 test/skill_contract_test.go diff --git a/.structlint.yaml b/.structlint.yaml index 51272e9..86a12bd 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -15,6 +15,7 @@ dir_structure: - ".claude/**" # Claude Code configuration - ".github/**" # GitHub configuration - "schema/**" # JSON Schema for editor autocomplete + - "skills/**" # Shipped agent skill (SKILL.md) disallowedPaths: - "vendor/**" # Vendor dependencies - "node_modules/**" # Node.js dependencies diff --git a/README.md b/README.md index 8611ad4..1382a46 100644 --- a/README.md +++ b/README.md @@ -731,10 +731,18 @@ repos: | [Configuration](docs/user/configuration.md) | Complete config reference | | [CLI Reference](docs/user/cli-reference.md) | Commands and options | | [CI/CD Integration](docs/user/ci-cd-integration.md) | Pipeline examples | +| [Violation Codes](docs/user/violation-codes.md) | Frozen list of all violation codes | | [AI Overview](docs/AI/overview.md) | AI context and architecture | | [Codebase Map](docs/AI/codebase-map.md) | File structure explained | | [Contributing](docs/AI/contributing.md) | How to contribute | +## For AI agents + +structlint ships a skill file for AI agents (Claude Code, MCP-based agents, etc.). Install it into your agent's skills directory: + +- [`skills/structlint/SKILL.md`](skills/structlint/SKILL.md) — when-to-run, violation-code decision table, machine contracts (`validate --format json`, `suggest --format json v1`), and the suggest → apply → re-validate fix loop. +- Codes are declared frozen and append-only — key on `code`, not on `message` text. + ## License MIT License - see [LICENSE](LICENSE) for details. diff --git a/docs/AI/overview.md b/docs/AI/overview.md index f77655f..5183585 100644 --- a/docs/AI/overview.md +++ b/docs/AI/overview.md @@ -1,6 +1,8 @@ # structlint - AI Context Overview > **For AI assistants:** This document provides the context you need to understand, use, and modify structlint. +> +> **If you are running structlint on behalf of a user** (validating, fixing violations, wiring hooks), read [`skills/structlint/SKILL.md`](../../skills/structlint/SKILL.md) instead — it is the agent-facing surface with machine contracts and the fix loop. The [violation codes reference](../user/violation-codes.md) declares codes frozen/append-only. ## What is structlint? diff --git a/docs/user/violation-codes.md b/docs/user/violation-codes.md new file mode 100644 index 0000000..983f230 --- /dev/null +++ b/docs/user/violation-codes.md @@ -0,0 +1,74 @@ +# Violation codes + +Every violation structlint emits carries a stable machine-readable `code` (visible in `--format json` and `--format sarif`). This document is the canonical reference. + +**The codes below are frozen and append-only.** Existing codes are never renamed or removed; new rules add new codes. If you're building tooling on top of structlint output — an editor plugin, a CI parser, an AI agent — key on `code`, not on `message` text. + +The 13 codes below correspond one-to-one to the `CodeDescriptions` registry in `internal/validator/codes.go`. Both directions are enforced by a test: adding a code without documenting it (or documenting one that doesn't exist) breaks the build. + +## Codes + +### `disallowed_directory` +Emitted by: `dir_structure.disallowedPaths` matching a directory. +Meaning: a directory that is explicitly forbidden was found. +Typical fix: remove or move the directory. The prohibition is deliberate — never loosen the rule to accommodate it. + +### `unallowed_directory` +Emitted by: `dir_structure.allowedPaths` not matching a directory. +Meaning: a directory exists that isn't allow-listed. +Typical fix: **config** if the directory is intentional (add a generalized glob to `allowedPaths`, or use `structlint suggest` to get the exact addition); **tree** if it's stray leftover. + +### `disallowed_file_pattern` +Emitted by: `file_naming_pattern.disallowed` matching a file. +Meaning: a file matches an explicitly forbidden name pattern (e.g. `.env*`, `*.log`, `.DS_Store`). +Typical fix: **tree.** Remove the file or move it out of scope. Never loosen the rule — these prohibitions typically guard secrets, junk, or files that shouldn't be committed. + +### `unallowed_file_pattern` +Emitted by: `file_naming_pattern.allowed` not matching a file. +Meaning: a file exists whose name isn't allow-listed. +Typical fix: **config** — add `*.ext` (or the exact name for extensionless files like `Makefile`) to `file_naming_pattern.allowed`. + +### `missing_required_directory` +Emitted by: `dir_structure.requiredPaths` referencing a missing directory. +Meaning: a directory the config declares required does not exist. +Typical fix: create the directory. + +### `missing_required_file` +Emitted by: `file_naming_pattern.required` with no file matching the pattern. +Meaning: no file matching the required pattern exists anywhere in the tree. +Typical fix: create the file (or the first pattern-matching file). + +### `placement_violation` +Emitted by: `placement[]` rule matching a file that isn't under the required root. +Meaning: a file that matches the rule's `files` pattern lives outside every `mustBeUnder` root. +Typical fix: `git mv` the file to a directory covered by `mustBeUnder`. `structlint suggest` produces the exact command. + +### `missing_required_group` +Emitted by: `requiredGroups[].oneOf` with none of the listed files present. +Meaning: none of the "one of" candidates exist (e.g. no `Makefile` OR `Taskfile.yml` OR `justfile`). +Typical fix: create at least one of the listed files. + +### `missing_required_group_match` +Emitted by: `requiredGroups[].eachDirMatching` with `requireMatch: true` and no directories matching. +Meaning: the pattern matched zero directories, so the group's per-directory checks never ran. +Typical fix: **config or tree** — either the pattern is wrong, or the expected directories are missing entirely. + +### `missing_group_file` +Emitted by: `requiredGroups[].mustContain` / `mustContainOneOf` inside a matching directory. +Meaning: a directory selected by `eachDirMatching` is missing a file it should contain (e.g. `cmd/*` matches `cmd/app/` but `cmd/app/main.go` is absent). +Typical fix: add the required file to that directory. + +### `boundary_violation` +Emitted by: `boundaries[]` rule matching a source file that imports a forbidden path. +Meaning: a Go / TypeScript / JavaScript / Python file imports something the rule forbids. +Typical fix: refactor the import. No mechanical suggestion — architectural intent lives in the boundary rules, and the fix depends on why the dependency exists. + +### `parse_error` +Emitted by: boundary rule failing to parse a source file's imports. +Meaning: the file is unparseable (syntactically broken source, unsupported language variant). +Typical fix: operational — fix the source file, or exclude it via `ignore`. + +### `walk_error` +Emitted by: any rule whose filesystem walk fails. +Meaning: an I/O error reached structlint (permission denied, missing directory, disk error). +Typical fix: operational — resolve the underlying filesystem issue. diff --git a/skills/structlint/SKILL.md b/skills/structlint/SKILL.md new file mode 100644 index 0000000..846df96 --- /dev/null +++ b/skills/structlint/SKILL.md @@ -0,0 +1,159 @@ +--- +name: structlint +description: > + Validate and fix project structure — file placement, naming, directory + layout, import boundaries. Use when the user says a file is in the wrong + place, when structlint reports violations, when adopting structlint on a + legacy repo, or when wiring pre-commit / CI checks. Trigger phrases: + "structlint violation", "where should this file go", "enforce directory + layout", ".structlint.yaml", "file in the wrong place". +--- + +# structlint + +structlint is a Go CLI that validates project structure against `.structlint.yaml`. This skill teaches you the machine surface (JSON contracts, exit codes) and the fix loop — you almost never need to hand-write configs; you can drive everything from `suggest`. + +## When to run + +- **Pre-commit (recommended)** — `structlint validate --staged --silent`. Diffs the git index (not HEAD), so a hook only lints what's actually being committed. Wire it with `structlint hook install` (auto-detects lefthook, pre-commit, or raw `.git/hooks`). +- **CI** — `structlint validate --format github` for inline annotations, `--format sarif` for code scanning, `--format json --json-output report.json` for artifact storage. Exit 0 = clean, 1 = violations. +- **Adopting on a legacy repo** — `structlint init --infer` walks the tree and writes a baseline that `validate` passes on. Tighten incrementally. Alternative: `structlint validate --json-output baseline.json` then commit and use `--baseline baseline.json` to grandfather existing drift while catching new drift. + +## Violation codes and how to fix + +The 13 codes below are **frozen**: names never change and never disappear. New rules add new codes. Full detail in [docs/user/violation-codes.md](../../docs/user/violation-codes.md). + +| Code | Emitted by | Typical fix | +|------|------------|-------------| +| `disallowed_directory` | `dir_structure.disallowedPaths` | **Tree.** Remove or move the directory — the prohibition is deliberate. | +| `unallowed_directory` | `dir_structure.allowedPaths` | **Config** when the dir is intentional (add generalized glob); **tree** if it's stray. | +| `disallowed_file_pattern` | `file_naming_pattern.disallowed` | **Tree.** Deliberate prohibition (secrets, backups, OS junk). Never loosen the rule. | +| `unallowed_file_pattern` | `file_naming_pattern.allowed` | **Config** — add `*.ext` (or exact name for extensionless files) if intentional. | +| `missing_required_directory` | `dir_structure.requiredPaths` | **Tree.** Create the directory. | +| `missing_required_file` | `file_naming_pattern.required` | **Tree.** Create the file (or first pattern-matching file). | +| `placement_violation` | `placement[]` | **Tree.** `git mv` the file under the rule's `mustBeUnder` root. | +| `missing_required_group` | `requiredGroups[].oneOf` | **Tree.** Create at least one of the listed files somewhere. | +| `missing_required_group_match` | `requiredGroups[].eachDirMatching` with `requireMatch: true` | **Config or tree** — the pattern matched no directories; either the pattern is wrong or the expected dirs are missing. | +| `missing_group_file` | `requiredGroups[].mustContain` / `mustContainOneOf` | **Tree.** Add the file to the matching directory. | +| `boundary_violation` | `boundaries[]` | **Tree.** Refactor the import — no mechanical fix; needs human judgment. | +| `parse_error` | Boundary rules parsing a source file | **Operational.** Fix the source file (or exclude it via `ignore`). | +| `walk_error` | Filesystem walk failure | **Operational.** Permissions, missing dir, disk error. | + +## Machine contracts + +### `validate --format json` + +```json +{ + "successes": 42, + "failures": 2, + "total_violations": 2, + "errors": ["Directory not in allowed list: tmp", "..."], + "violations": [ + { + "code": "unallowed_directory", + "severity": "error", + "path": "tmp", + "rule": "dir_structure.allowedPaths", + "message": "Directory not in allowed list: tmp" + } + ], + "summary": { + "total_successes": 42, + "total_failures": 2, + "violations": [{"type": "unallowed_directory", "count": 1, "examples": [...], "description": "Directories not in the allowed list"}] + } +} +``` + +The `violations[]` array is the stable contract to key on. Group by `.code`, not by parsing `.message`. + +### `suggest --format json` (v1) + +```json +{ + "version": 1, + "configPath": ".structlint.yaml", + "proposals": [ + {"kind": "config_add", "section": "dir_structure.allowedPaths", "value": "tools/**", "reason": "unallowed_directory: ...", "paths": ["tools"]}, + {"kind": "move", "from": "stray.sql", "to": "migrations/stray.sql", "command": "git mv stray.sql migrations/stray.sql", "reason": "...", "paths": ["stray.sql"]}, + {"kind": "create", "path": "README.md", "reason": "missing_required_file: ...", "paths": ["README.md"]}, + {"kind": "note", "reason": "boundary_violation: ... — no mechanical fix", "paths": ["..."]} + ], + "configDiff": "--- a/.structlint.yaml\n+++ b/.structlint.yaml\n@@ ..." +} +``` + +`configDiff` is a real unified diff built by inserting into the original config text — so it preserves comments/order/quoting and applies cleanly with `patch -p1`. **`suggest` never proposes loosening `disallowed` / `disallowedPaths`.** + +### Exit codes + +- `validate`: **0** clean, **1** violations found, **2** config error, **3** runtime error. +- `suggest`: **0** always when it ran (even with proposals present — advisory), **non-zero** only on operational error (no config, unreadable tree, bad flag). + +## The fix loop + +```bash +structlint suggest --format json > /tmp/report.json + +# 1. Config changes: apply the diff. +jq -r .configDiff /tmp/report.json | patch -p1 + +# 2. Placement violations: run the git mv commands. +jq -r '.proposals[] | select(.kind=="move") | .command' /tmp/report.json | sh + +# 3. Missing paths: create them. +jq -r '.proposals[] | select(.kind=="create") | .path' /tmp/report.json | xargs -I{} sh -c 'mkdir -p $(dirname "{}") && touch "{}"' + +# 4. Verify. +structlint validate +``` + +Iterate until `validate` exits 0. `note` proposals are for a human — read them, don't blindly automate. + +## Setup recipes + +**Pre-commit hook (any framework)**: +```bash +structlint hook install # auto-detects lefthook / pre-commit / git +structlint hook install --type git --dry-run # preview +``` + +**pre-commit framework (`.pre-commit-config.yaml`)**: +```yaml +repos: + - repo: https://github.com/AxeForging/structlint + rev: v0.6.0 + hooks: + - id: structlint +``` + +**GitHub Actions**: +```yaml +- uses: AxeForging/structlint@main + with: + config: .structlint.yaml + comment-on-pr: "true" +``` + +**Sharing config across repos (`extends`)** — requires structlint ≥ v0.6.0: +```yaml +# requires structlint >= v0.6.0 +extends: go-standard # or node-standard, python-standard, generic +dir_structure: + allowedPaths: + - "tools/**" # additions on top of the preset +``` + +**Editor autocomplete** — add to the top of `.structlint.yaml`: +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/AxeForging/structlint/main/schema/structlint.schema.json +``` + +## Gotchas + +- **Config parse is strict.** `allowed_paths:` (typo) fails at load time, not silently. Unknown keys → error. This is intentional — it catches CI drift. +- **`extends` requires a newer binary.** An old structlint reading a config with `extends` fails with `field extends not found in type config.Config`. Pin your CI action / pre-commit `rev` to a version that supports it. +- **`--changed-only` diffs HEAD; `--staged` diffs the index.** Pre-commit hooks want `--staged` — otherwise you lint the working tree, not the commit. +- **`ignore` ≠ `disallowed`.** `ignore` says "don't look here"; `disallowed` says "look here and complain". Vendor / node_modules → `ignore`. `.env` / secrets → `disallowed`. +- **Globs are relative to `--path`, not to the config file.** A repo-root config discovered from a subdirectory still evaluates patterns against `--path`. diff --git a/test/skill_contract_test.go b/test/skill_contract_test.go new file mode 100644 index 0000000..1076a29 --- /dev/null +++ b/test/skill_contract_test.go @@ -0,0 +1,107 @@ +package test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/AxeForging/structlint/internal/validator" +) + +// TestViolationCodesDoc_CoversRegistry is the keystone: every code in the +// canonical CodeDescriptions registry must have a heading in the frozen +// violation-codes doc. This locks the append-only contract that spec 011 +// declares (agents key on codes; codes are documented; no drift). +func TestViolationCodesDoc_CoversRegistry(t *testing.T) { + docPath := filepath.Join(repoRoot(t), "docs", "user", "violation-codes.md") + data, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("read violation-codes.md: %v", err) + } + body := string(data) + for code := range validator.CodeDescriptions { + needle := "`" + code + "`" + if !strings.Contains(body, needle) { + t.Errorf("violation-codes.md missing entry for code %q", code) + } + } +} + +// TestViolationCodesDoc_NoUnknownCodes asserts that every code-formatted +// entry in the doc corresponds to a real registered code. Catches typos +// and stale renames in the doc itself. +func TestViolationCodesDoc_NoUnknownCodes(t *testing.T) { + docPath := filepath.Join(repoRoot(t), "docs", "user", "violation-codes.md") + data, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("read: %v", err) + } + // Heading form we use in the doc: `### \`code\``. + re := regexp.MustCompile("### `([a-z_]+)`") + matches := re.FindAllStringSubmatch(string(data), -1) + if len(matches) == 0 { + t.Fatal("no code headings found in violation-codes.md — regex mismatch or empty doc") + } + for _, m := range matches { + code := m[1] + if _, ok := validator.CodeDescriptions[code]; !ok { + t.Errorf("violation-codes.md documents unknown code %q (not in CodeDescriptions registry)", code) + } + } +} + +// TestSkillFile_ExistsWithFrontmatter asserts the shipped skill file exists +// with the frontmatter fields agent runtimes look for. +func TestSkillFile_ExistsWithFrontmatter(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "structlint", "SKILL.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + body := string(data) + if !strings.HasPrefix(body, "---\n") { + t.Errorf("SKILL.md must start with a frontmatter block (---); got: %q", body[:min(64, len(body))]) + } + if !strings.Contains(body, "name: structlint") { + t.Errorf("SKILL.md frontmatter must declare name: structlint") + } + if !strings.Contains(body, "description:") { + t.Errorf("SKILL.md frontmatter must declare a description") + } +} + +// TestSkillFile_MentionsAllCodes asserts SKILL.md's decision table covers +// every registry code so agents have complete fix-or-config guidance. +func TestSkillFile_MentionsAllCodes(t *testing.T) { + path := filepath.Join(repoRoot(t), "skills", "structlint", "SKILL.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + body := string(data) + for code := range validator.CodeDescriptions { + if !strings.Contains(body, "`"+code+"`") { + t.Errorf("SKILL.md missing code %q from decision table", code) + } + } +} + +// TestSelfValidation_AllowsSkillsDir builds the binary and validates the +// repo root, proving skills/** landed in .structlint.yaml in the same PR +// as the new directory (roadmap risk 5). +func TestSelfValidation_AllowsSkillsDir(t *testing.T) { + bin := buildBinary(t) + out, err := runBinaryInDir(t, bin, repoRoot(t), "validate", "--silent") + if err != nil { + t.Fatalf("self-validate must pass with skills/ present; err=%v out:\n%s", err, out) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}