Metadata-Version: 2.5
Name: codedecorum
Version: 0.1.3
Summary: Run code-backed policy rules across Git repository changes
Project-URL: Homepage, https://github.com/mwrshah/codedecorum
Project-URL: Repository, https://github.com/mwrshah/codedecorum
Project-URL: Issues, https://github.com/mwrshah/codedecorum/issues
Author: Munawar Shah
License-Expression: MIT
License-File: LICENSE
Keywords: ai,code-quality,git,linter,policy,tree-sitter
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.11
Requires-Dist: pygments>=2.19.2
Requires-Dist: tree-sitter-language-pack<2,>=1.13.6
Requires-Dist: tree-sitter<0.27,>=0.25.2
Description-Content-Type: text/markdown

# Codedecorum

[![PyPI](https://img.shields.io/pypi/v/codedecorum)](https://pypi.org/project/codedecorum/)
[![Python](https://img.shields.io/pypi/pyversions/codedecorum)](https://pypi.org/project/codedecorum/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)

Codedecorum is a configurable, extensible, cross-language command-line linter that keeps AI-generated code on the rails. Repositories choose their rules; violations fail the check with location breadcrumbs and a configurable message.

Pygments provides broad lexical tokens, while Tree-sitter grammars downloaded and cached on demand provide lazy concrete syntax trees for semantic rules. A language's first semantic check for each language-pack version requires network access; later runs reuse its cached grammar. Download or loading failures stop linting rather than silently disabling semantic rules.

## Table of Contents

1. [Bundled rules](#bundled-rules)
2. [Installation](#installation)
3. [Configure Lefthook](#configure-lefthook)
4. [Configuration](#configuration)
5. [Suppressions](#suppressions)
6. [How to write a plugin](#how-to-write-a-plugin)
7. [License](#license)

## Bundled rules

1. `CD001` rejects standalone and multiline comments outside an optional contiguous file header (up to 1,000 characters by default). Excludes directives such as shebangs and linter suppressions. Options: `allow-file-header` (`bool`) and `max-file-header-chars` (`int`).
2. `CD002` rejects trailing comments longer than 60 characters by default. Excludes directives such as shebangs and linter suppressions. Option: `max-length` (`int`).
3. `CD003` rejects Python `pass` statements, `TODO` markers in comments or identifiers, and `throw` statements whose message or exception type marks them as unimplemented placeholders.
4. `CD004` rejects pytest/unittest-style Python test functions, Python doctests, and common JavaScript/TypeScript test blocks in implementation files. Recognized test files and Storybook story files remain valid separate artifacts.
5. `CD005` flags every test file included by the active scope.
6. `CD006` rejects changed files matching configured root-relative, case-sensitive path patterns unless that rule-path pair has a current temporary unlock. Option: `paths` (array of non-empty glob patterns, default `[]`).

## Installation

Run Codedecorum with [uv](https://docs.astral.sh/uv/):

```bash
uvx codedecorum .
```

To keep it on your `PATH`, install it as a tool:

```bash
uv tool install codedecorum
```

`pipx install codedecorum` and `pip install codedecorum` also work.

## Configure Lefthook

Lefthook invokes Codedecorum across the repository before each commit:

```yaml
pre-commit:
  commands:
    codedecorum:
      run: uvx codedecorum .
```

## Configuration

### Resolution order

Codedecorum selects one configuration in this order:

1. A file passed with `--config`
2. `.codedecorum.toml` in the repository root
3. `codedecorum.toml` in the repository root
4. Exactly one configured project manifest: `[tool.codedecorum]` in `pyproject.toml`, `"codedecorum"` in `package.json`, or one of `[workspace.metadata.codedecorum]` and `[package.metadata.codedecorum]` in `Cargo.toml`. Multiple configured project manifests—or both Cargo tables—produce an ambiguity error.
5. Global configuration at `$XDG_CONFIG_HOME/codedecorum/codedecorum.toml`, `~/.config/codedecorum/codedecorum.toml` when `XDG_CONFIG_HOME` is unset, or `%APPDATA%/codedecorum/codedecorum.toml` on Windows
6. Built-in defaults: all rules enabled, and default values for rules.

### Available settings

- `scope`: `"diff-lines"` (default), `"diff-files"`, or `"full"`
- `base`: the Git revision used by diff scopes; defaults to `"HEAD"`
- `include`: case-sensitive glob patterns that force matching paths into source checks; defaults to `[]`
- `exclude`: case-sensitive glob patterns omitted from source checks; defaults to `[]`
- `plugin-dirs`: directories containing rule plugins; defaults to `[]`
- `rules`: per-rule overrides; defaults to `{}`, meaning all loaded rules remain enabled with their built-in options. Set `enabled = false` to disable a rule, or set one of its options to replace that option's default

**Note:** Path patterns use root-relative POSIX paths, and inclusion overrides exclusion. Plugin directories expand `~`; relative directories resolve from the configuration file's directory.

### Standalone TOML

Use this format in `.codedecorum.toml`, `codedecorum.toml`, or the global configuration file:

```toml
scope = "diff-lines"
base = "HEAD"
include = ["src/**", "tests/**"]
exclude = ["vendor/**"]
plugin-dirs = ["~/company-rules"]

[rules.CD001]
enabled = false
```

### pyproject.toml

Place the same settings under `[tool.codedecorum]`:

```toml
[tool.codedecorum]
scope = "diff-lines"
base = "HEAD"
include = ["src/**", "tests/**"]
exclude = ["vendor/**"]
plugin-dirs = ["./my-ai-rails"]

[tool.codedecorum.rules.CD001]
enabled = true
max-file-header-chars = 2000
```

### Cargo.toml

For a workspace, place the settings under `[workspace.metadata.codedecorum]`:

```toml
[workspace.metadata.codedecorum]
scope = "diff-lines"
base = "HEAD"
include = ["src/**", "tests/**"]
exclude = ["vendor/**"]

[workspace.metadata.codedecorum.rules.CD001]
enabled = true
max-file-header-chars = 2000
```

For a package, use `[package.metadata.codedecorum]` instead:

```toml
[package.metadata.codedecorum]
scope = "diff-lines"
base = "HEAD"
include = ["src/**", "tests/**"]
exclude = ["vendor/**"]
plugin-dirs = ["~/company-rules"]

[package.metadata.codedecorum.rules.CD001]
max-file-header-chars = 2000
```

### package.json

Use the same kebab-case setting names under `"codedecorum"`:

```json
{
  "codedecorum": {
    "scope": "diff-lines",
    "base": "HEAD",
    "include": ["src/**", "tests/**"],
    "exclude": ["vendor/**"],
    "plugin-dirs": ["./company-rules"],
    "rules": {
      "CD001": {
        "enabled": false
      }
    }
  }
}
```

### Explicit file

Pass a configuration file directly to bypass discovery:

```bash
codedecorum --config ~/.config/codedecorum/codedecorum.toml .
```

### CLI flags

Command-line flags select or override configuration for one run:

- `--config PATH`: use a specific configuration file instead of discovery
- `--scope {diff-lines,diff-files,full}`: override the configured scope
- `--base REVISION`: override the Git revision used by diff scopes
- `--disable RULE`: disable a configured or bundled rule; repeat for additional rules
- `--unlocking-with-explicit-human-approval RULE PATH`: temporarily ignore one rule for one file; `--approval-timeout DURATION` overrides the five-minute default
- `--version`: print the installed version and exit
- `paths`: check these files or directories; defaults to the current directory

Paths must belong to one Git repository. Rule-specific options remain config-only.

A command can combine all applicable options:

```bash
codedecorum \
  --config ~/.config/codedecorum/codedecorum.toml \
  --scope diff-files \
  --base origin/main \
  --disable CD001 \
  --disable DOC001 \
  src tests
```

Exit status is `0` when checks pass, `1` when rules report violations, and `2` for configuration or operational errors.

## Suppressions

Put `# codedecorum: ignore CD004 CD005` (or the `//` equivalent) on the first physical line to permanently suppress exact rules, or use `# codedecorum: ignore all` to skip the file entirely; unknown codes are ignored, while wildcards plus inline or range suppressions are unsupported.

For a temporary rule-and-file suppression, run `codedecorum --unlocking-with-explicit-human-approval RULE PATH`; it expires after 5m unless `--approval-timeout DURATION` is supplied.

## How to write a plugin

Store repository-specific plugins in a tracked directory and register it in the repository's `.codedecorum.toml`:

```toml
plugin-dirs = ["codedecorum-rules"]
```

Relative plugin directories resolve from the configuration file's directory. Each immediate non-private `.py` file in the directory exports one `RULE`. Plugin directories are non-recursive. Plugins are trusted Python and execute inside the Codedecorum process.

### Scopes and rule units

`LINE`, `FILE`, and `PATH` are part of a rule's definition. A rule declares the smallest unit that can be checked without hiding violations:

- `LINE` reports source ranges whose validity depends only on those lines. In `diff-lines`, only findings intersecting changed new-side lines survive. This fits forbidden calls, local syntax policies, and bounded trailing comments.
- `FILE` receives complete source whenever its file is in scope. This fits policies where an unchanged line can become invalid because code elsewhere changed, including file headers, imports, declarations, or relationships within one file.
- `PATH` receives a root-relative `PurePosixPath` without reading the file. This fits naming, extension, ownership, and directory-layout policies. Path rules see files excluded from source-content checks.

`changed_only=True` limits a rule to changed instances of its unit even if scope is `diff-files` or `full`: changed lines for `LINE`, whole changed files for `FILE`, and changed paths for `PATH`.

The configured scope determines which units run. `diff-lines` is the default: it filters line findings to changed lines while promoting file and path rules to changed files. `diff-files` emits every finding from changed files. `full` checks the complete Git-visible inventory.

Diff scopes compare the working tree with `git merge-base <base> HEAD`, including branch commits, staged changes, unstaged changes, and untracked files. Deleted files are omitted and renamed files use their destination path. `HEAD` is the default base; CI should name its target branch explicitly.

Git standard exclusions control inventory. Tracked files remain visible if a new ignore pattern matches them, ignored untracked files stay hidden, and explicitly named files bypass ignore discovery.

```python
from collections.abc import Iterable, Mapping
from fnmatch import fnmatchcase
from pathlib import PurePosixPath

from codedecorum.api import Finding, Option, Rule, RuleUnit


def string_patterns(value: object) -> bool:
    return isinstance(value, (list, tuple)) and all(
        isinstance(pattern, str) and pattern for pattern in value
    )


def check(context: object, options: Mapping[str, object]) -> Iterable[Finding]:
    if not isinstance(context, PurePosixPath) or context.suffix != ".md":
        return
    path = context.as_posix()
    if not any(fnmatchcase(path, pattern) for pattern in options["allowed-paths"]):
        yield Finding(detail="Markdown belongs under docs")


RULE = Rule(
    code="DOC001",
    name="Markdown location",
    guidance="Keep Markdown under the configured documentation paths.",
    check=check,
    options={"allowed-paths": Option(("docs/*.md",), string_patterns)},
    unit=RuleUnit.PATH,
)
```

Every rule provides a stable code, name, guidance, check function, semantic unit, and optional validated settings. Guidance may be a non-empty string or a callable that receives the resolved option mapping and returns a non-empty string.

```python
guidance = lambda options: f"Keep lines within {options['max-length']} characters."
```

`LINE` and `FILE` checks receive a lazy `SourceContext`; `PATH` checks receive `PurePosixPath`. Checks yield `Finding` values. Line rules require complete 1-based, end-exclusive source ranges. File rules may also yield file-level findings, while path rules yield only path-level findings.

`SourceContext` exposes the root-relative path, lazy source text, position conversion, and a cached tuple of `SyntaxToken(offset, end_offset, type, value)` values. `offset` and `end_offset` are zero-based, end-exclusive character offsets into the physical source, including embedded languages; `value` is the exact source text, and `type` is a Pygments token type for comparison with families from `pygments.token`—for example, `SyntaxToken(0, 4, Token.Keyword, "pass")`. Unsupported filenames and lexers that do not return the source losslessly produce no tokens. Semantic rules can instead access `context.syntax`, which detects the language and lazily downloads, caches, and parses its Tree-sitter grammar before returning a cached `SyntaxContext` with the language, syntax root, exact node text, and `syntax.finding(node)` source-range conversion; unsupported languages return `None`. The engine validates plugin declarations and findings, attaches path and source metadata, and groups diagnostics by file and rule.

## License

This repository is licensed under the [MIT License](LICENSE).
