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
8 changes: 8 additions & 0 deletions architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ matching `architecture/<capability>.md` in the **same PR** that ships the code.
The edit rides in the implementing diff and is reviewed with it — never applied
as a separate post-merge step. The change bundle in `planning/changes/` stays as
the *why*; these files are the *what is true now*.

## Capabilities

- [`eof-normalization.md`](eof-normalization.md) — the core: binary skip, the
none/append_lf/truncate action model, terminator handling, in-place mutation.
- [`file-discovery.md`](file-discovery.md) — directory input, `.gitignore`
rules, path resolution, open modes.
- [`cli.md`](cli.md) — command-line interface, output, and exit-code contract.
30 changes: 30 additions & 0 deletions architecture/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# CLI

The command-line surface and its contract with callers (shells, CI, pre-commit).

## Interface

- `eof-fixer <path>` — fix every non-ignored text file under `<path>` in place.
- `eof-fixer <path> --check` — report what would change, write nothing.

`<path>` is required and must be a directory; otherwise argparse exits 2 with a
usage error. See [file-discovery](file-discovery.md) for what counts as a
visited file.

## Output

For each file that needs (or would need) fixing, the tool writes `Fixing
<filename>\n` to stdout — the relative path as discovery yielded it. Output goes
through `sys.stdout.write`, not `print`.

## Exit code

`main()` accumulates a result by OR-ing each file's outcome:

- **0** — every file already ended correctly (nothing changed / nothing would
change).
- **1** — at least one file needed fixing. In fix mode it was fixed; in
`--check` mode it was only flagged.

This makes `--check` a CI/pre-commit gate: exit 1 signals "files are
non-conforming" without mutating the tree.
55 changes: 55 additions & 0 deletions architecture/eof-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# EOF normalization

The core capability: given a single open file, decide whether its end-of-file
terminator is correct and, in fix mode, make it so. "Correct" means the file
ends with **exactly one** line terminator — no missing newline, no trailing
blank lines.

## Binary skip

Before inspecting terminators, the file is sampled: the first 1024 bytes are
read and if any contain a null byte (`\x00`), the file is treated as binary and
left untouched. The sample read restores the original stream position, so
detection is side-effect-free. This is a heuristic, not a content-type sniff —
text files never contain null bytes; most binary formats do within the first
kilobyte.

## The action model

Inspection returns one of three actions, derived purely from the file's tail:

- **`none`** — nothing to do. The file is empty (a seek to the last byte
fails), or it already ends with exactly one terminator.
- **`append_lf`** — the last byte is not a terminator, so a single `\n` is
appended.
- **`truncate(offset)`** — there are excess trailing terminators; the file is
cut to `offset`. `offset == 0` means the file is **all** terminators and is
truncated to empty.

This split keeps the *decision* (read-only, used by check mode) separate from
the *mutation* (write mode), so the same logic drives both modes.

## Terminator handling

Detection walks backwards from the last byte over the run of `\n`/`\r`
characters to find where real content ends. The first terminator sequence
immediately after that content determines what "exactly one terminator" means
for this file — checked in order `\n`, `\r\n`, `\r`. So a file ending in one
`\r\n` is already correct and left alone, while one ending in `\r\n\r\n` is
truncated back to a single `\r\n`. Mixed and legacy line endings are respected:
the existing terminator style is preserved, only the *count* is normalized.

## Mutation

In fix mode:

- `append_lf` seeks to end-of-file before writing the `\n`. The explicit seek
is required on Windows, where a read-then-write on a `rb+` stream otherwise
raises.
- `truncate` seeks to the computed offset and truncates there.

In check mode no bytes are written; the action is computed and reported only.

Either way, a file that needed changing contributes a non-zero result to the
caller — see [cli](cli.md) for how that becomes the process exit code and the
`Fixing <file>` line.
36 changes: 36 additions & 0 deletions architecture/file-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# File discovery

Which files the tool visits, and which it skips, when handed a directory.

## Input

The CLI takes a single directory path. A path that is not a directory is a
usage error (argparse `parser.error`, exit 2) — the tool never operates on a
lone file argument.

## Ignore rules

Traversal is driven by `pathspec.GitIgnoreSpec`, i.e. real `.gitignore`
semantics. The ignore set is built from:

- A fixed baseline: `.git`, `.cache`, `.uv-cache` (the last two are uv's
caches, which would otherwise be walked).
- The directory's own `.gitignore`, if present — its lines are appended to the
baseline.

`match_tree_files(path, negate=True)` then yields the files **not** ignored.
The spec is loaded once and applied to the whole tree.

## Path resolution

`match_tree_files` yields paths **relative to the scanned directory**, not the
caller's working directory. Each is rejoined to the input path (`path /
filename`) before opening, so the tool behaves the same regardless of where it
is invoked from.

## Open mode

Files are opened in binary: `rb` in check mode (read-only, no accidental
writes) and `rb+` in fix mode (in-place read/write). Binary mode is what lets
[EOF normalization](eof-normalization.md) reason about exact terminator bytes
and seek from the end without newline translation.