Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/AI/overview.md
Original file line number Diff line number Diff line change
@@ -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?

Expand Down
74 changes: 74 additions & 0 deletions docs/user/violation-codes.md
Original file line number Diff line number Diff line change
@@ -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.
159 changes: 159 additions & 0 deletions skills/structlint/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`.
107 changes: 107 additions & 0 deletions test/skill_contract_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading