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
4 changes: 4 additions & 0 deletions .github/actions/prose-gate/prose-gate.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"--exclude": { "action": "append", "default": [] },
"--summary": { "action": "store_true" },
"--list-files": { "action": "store_true", "help": "print the discovered file set and exit, for auditing the sweep scope" },
"--skip-generated": {
"action": "store_true",
"help": "drop a named file inside a generated tree, as a directory scan does, for a hook naming a commit's paths"
},
"--diff": {
"metavar": "BASE",
"help": "only report violations on lines changed vs BASE (matches the repo policy: fix as each file is next edited, not swept)"
Expand Down
44 changes: 34 additions & 10 deletions .github/actions/prose-gate/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,14 @@ def gate_provenance(explicit: str | None = None) -> str:
)


def git_env() -> dict[str, str]:
"""This process's environment without the variables that tell git which repository to use."""
return {k: v for k, v in os.environ.items() if k not in GIT_LOCATION}


def checkout_provenance(script: Path) -> str:
"""The commit describing this file, `-dirty` where edited, unknown where git tracks it not."""
env = {k: v for k, v in os.environ.items() if k not in GIT_LOCATION}
env = git_env()
where = ("-C", str(script.parent))
if git_text(*where, "ls-files", "--error-unmatch", "--", str(script), env=env) is None:
return "unknown"
Expand Down Expand Up @@ -493,10 +498,19 @@ def printable(line: str) -> str:
return escaped.encode("utf-8", "backslashreplace").decode("utf-8")


def lookup_start(path: Path) -> Path:
"""The directory a git lookup for `path` starts in, the path itself or the file's directory."""
return path if path.is_dir() else path.parent


def repo_root(path: Path) -> str:
"""The repository top level containing `path`, or '' when git cannot say."""
start = path if path.is_dir() else path.parent
return (git_text("-C", str(start), "rev-parse", "--show-toplevel") or "").strip()
"""The repository top level containing `path`, or '' when git cannot say.

A hook in a linked worktree inherits `GIT_DIR`, under which git reports whatever directory it
starts in as the top level. So the lookup drops the location variables, as provenance does.
"""
start = lookup_start(path)
return (git_text("-C", str(start), "rev-parse", "--show-toplevel", env=git_env()) or "").strip()


def tracked_paths(root: Path) -> list[Path] | None:
Expand Down Expand Up @@ -532,17 +546,26 @@ def shallow_checkout(root: Path) -> bool:


def discover(
paths: list[str], excludes: tuple[str, ...] = (), root: Path | None = None
paths: list[str],
excludes: tuple[str, ...] = (),
root: Path | None = None,
skip_generated: bool = False,
) -> list[Path]:
"""Every authored text file under the paths, scoped by git where git can describe the tree."""
"""Every authored text file under the paths, scoped by git where git can describe the tree.

A named file is read even inside a generated tree, unless `skip_generated` drops it there as a
wider scan does. A hook naming a commit's paths sets it, so it keeps no copy of the tree list.
"""
found: list[tuple[Path, str]] = []
for raw in paths:
p = Path(raw)
base = p if p.is_dir() else (p.parent if p.is_file() else Path("."))
anchor = root if root is not None else Path(repo_root(base) or base)
trees = config_for(str(anchor)).exempt["generated_trees"]
if p.is_file():
found.append((p, repo_key(p, anchor)))
key = repo_key(p, anchor)
if not skip_generated or trees.isdisjoint(Path(key).parts):
found.append((p, key))
continue
candidates = tracked_paths(base)
# Whether git can describe a tree is settled by asking git, never by its answer's size.
Expand Down Expand Up @@ -2032,18 +2055,19 @@ def main(argv: list[str] | None = None) -> int:
# Anything that is not a file or a directory, a FIFO or a socket included, is refused.
if unusable := [p for p in scan_paths if not (Path(p).is_file() or Path(p).is_dir())]:
return refuse("error-not-a-path", paths=quoted(unusable))
git_roots = {found for found in (repo_root(Path(p)) for p in scan_paths) if found}
starts = {lookup_start(Path(p)) for p in scan_paths}
git_roots = {found for found in map(repo_root, starts) if found}
if len(git_roots) > 1:
return refuse("error-many-repositories", roots=quoted(git_roots))
first, in_git = Path(scan_paths[0]), bool(git_roots)
scan_root = Path(git_roots.pop()) if git_roots else first if first.is_dir() else first.parent
scan_root = Path(git_roots.pop()) if git_roots else lookup_start(first)
try:
config_for(str(scan_root))
except OverrideError as exc:
return refuse("error-override", path=scan_root / OVERRIDE_NAME, why=exc)
stand_downs(rules, a, scan_root, in_git)

files = discover(scan_paths, tuple(a.exclude), scan_root)
files = discover(scan_paths, tuple(a.exclude), scan_root, a.skip_generated)
keys = {f: repo_key(f, scan_root) for f in files}
if a.list_files:
for f in files:
Expand Down
16 changes: 13 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,24 @@ fi
# The base is HEAD, or MERGE_HEAD on a merge commit, so the lines a merged-in parent brought are not read as added.
# A rule is applied as a file is next edited rather than swept across the tree.
# Whole-tree costs about 2.2 seconds where the diff-scoped run costs about 0.15.
# The scope is the working tree rather than the index.
# A partially staged file is therefore judged on all of its edits, not only the staged ones.
# The gate reads the paths this commit stages, so an unrelated untracked file cannot refuse it.
# It reads their working-tree content, so a partially staged file is judged on all of its edits.
# A path that is no longer a file is dropped, since the gate refuses a submodule or a deleted path.
# An empty list skips the gate, which given no path would scan the whole tree.
# The inner sh checks for that itself, since GNU xargs runs once on empty input and BSD xargs does not.
# The list is read once on its own first, because a pipeline hides a failure of its first command.
# `--skip-generated` passes over a staged file in a generated tree, as a whole-tree scan does.
# The pull request gate reads every line the branch changes against its base.
base=HEAD
if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
base=MERGE_HEAD
fi
uv run --no-project --script .github/actions/prose-gate/prose_lint.py . --diff "$base"
git diff --cached --name-only --diff-filter=ACMRT -z >/dev/null
# shellcheck disable=SC2016 # Each expansion belongs to the inner sh.
git diff --cached --name-only --diff-filter=ACMRT -z |
xargs -0 sh -c 'base=$1; shift; for p do shift; if [ -f "$p" ]; then set -- "$@" "$p"; fi; done
[ "$#" -eq 0 ] || exec uv run --no-project --script .github/actions/prose-gate/prose_lint.py \
--skip-generated --diff "$base" -- "$@"' sh "$base"

# The eol check is repo-wide rather than diff-scoped, and it is here because it is already fast.
# It reads `.gitattributes` against `.editorconfig` for the whole repository and takes no file list.
Expand Down
4 changes: 2 additions & 2 deletions catalog/snippets/husky/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ runs **language formatting/lint and the fleet's shared doc gates**. For .NET it
`dotnet format` style via `dotnet husky run`. For a Python repo it runs `ruff format --check` /
`ruff check` / the repo's type checker (native tooling, no Docker, the same checks the
`../pre-commit/.pre-commit-config.yaml` snippet runs). It also runs the diff-scoped
prose/comment-style gate and the whole-tree line-ending check. Copy `../hub_fetch_run.py`
prose/comment-style gate over the paths the commit stages, and the whole-tree line-ending check. Copy `../hub_fetch_run.py`
alongside `pre-commit` for the doc gates to run. The script fetches those two checks fresh from
`ptr727/ProjectTemplate`'s `main` branch and runs them, rather than vendoring or pinning a copy.
A pin that nothing keeps current goes stale by construction. CI (this repo's own, and the hub's) is
Expand All @@ -28,7 +28,7 @@ runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tas
prose gate in whole-repo mode, for on-demand full-tree validation beyond the diff-scoped
commit-time run. Its `Lint: EOL` task runs the same whole-tree EOL check the hook runs.
Keeping the Docker-dependent doc linters out of the hook is what keeps it fast. Docker is the
dependency it stays free of, though the hook still needs network for the two
dependency it stays free of, though the hook still needs network for the
`hub_fetch_run.py` calls above. A fully offline clone therefore fails the commit loudly on the
fetch rather than silently skipping the doc gates.

Expand Down
17 changes: 14 additions & 3 deletions catalog/snippets/husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ else
exit 1
fi

# The prose gate scopes to the working tree against HEAD, not only the staged index.
# A partially staged file is judged on all of its edits, not only the staged ones.
# The prose gate reads the paths this commit stages, so an unrelated untracked file cannot refuse it.
# It reads their working-tree content, so a partially staged file is judged on all of its edits.
# A path that is no longer a file is dropped, since the gate refuses a submodule or a deleted path.
# An empty list skips the gate, which given no path would scan the whole tree.
# The inner sh checks for that itself, since GNU xargs runs once on empty input and BSD xargs does not.
# The list is read once on its own first, because a pipeline hides a failure of its first command.
# `--skip-generated` passes over a staged file in a generated tree, as a whole-tree scan does.
# The run_py function is out of reach of xargs, so uv chooses the interpreter, which hub_fetch_run.py needs anyway.
# The pull request gate reads every line the branch changes against its base.
run_py "$hub_fetch_run" .github/actions/prose-gate/prose_lint.py . --diff HEAD || exit 1
git diff --cached --name-only --diff-filter=ACMRT -z >/dev/null || exit 1
# shellcheck disable=SC2016 # Each expansion belongs to the inner sh.
git diff --cached --name-only --diff-filter=ACMRT -z |
xargs -0 sh -c 'fetch=$1; shift; for p do shift; if [ -f "$p" ]; then set -- "$@" "$p"; fi; done
[ "$#" -eq 0 ] || exec uv run --no-project python "$fetch" .github/actions/prose-gate/prose_lint.py \
--skip-generated --diff HEAD -- "$@"' sh "$hub_fetch_run" || exit 1
run_py "$hub_fetch_run" .github/actions/repo-gate/repo_gate.py --check eol || exit 1
3 changes: 1 addition & 2 deletions catalog/snippets/pre-commit/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ repos:
pass_filenames: false
- id: prose-gate
name: prose/comment-style gate (diff-scoped, fetched fresh from ProjectTemplate@main)
entry: uv run --no-project python hub_fetch_run.py .github/actions/prose-gate/prose_lint.py --diff HEAD --
entry: uv run --no-project python hub_fetch_run.py .github/actions/prose-gate/prose_lint.py --skip-generated --diff HEAD --
language: system
exclude: (^|/)reports/
require_serial: true
- id: eol-gate
name: line-ending consistency gate (fetched fresh from ProjectTemplate@main)
Expand Down
5 changes: 2 additions & 3 deletions catalog/snippets/pre-commit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ the Husky.Net shape judges all of them. That set-aside covers tracked files only
reads the paths `pre-commit` passes it rather than scanning `.`, which keeps an untracked file out
of the check. Those are the staged paths, or the conflicted ones while a merge is in progress.
`require_serial` keeps `pre-commit` from running the gate in parallel batches, each of which would
fetch it again. A path named that way is judged even inside a generated tree. So the
hook's `exclude` mirrors the gate's default `generated_trees`, and a repo adding to that list adds
the same names there. The pull request gate reads every line the branch changes against its base. A repo with no Python drops the `ruff-format`,
fetch it again. `--skip-generated` passes over a named path inside a generated tree, as a whole-tree
scan does. So the hook reads the gate's `generated_trees` rather than keeping a copy of it. The pull request gate reads every line the branch changes against its base. A repo with no Python drops the `ruff-format`,
`ruff-check` and `type-check` hooks and keeps the doc gates.
That is what a Docker, config, or docs repo wires.

Expand Down
4 changes: 2 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Repo Scripts

The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches. `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. `uv run --no-project --script .github/actions/prose-gate/prose_lint.py . --diff HEAD` is diff-scoped, at about 0.15 seconds where its whole-tree sweep costs about 2.2. `python3 scripts/repo_gate.py --check eol` is repo-wide, since it reads `.gitattributes` against `.editorconfig` and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode. What reversed it is the diff scope rather than a change of preference. The gates needing Docker, and `sha-pin` which resolves a pin against the GitHub API, stay in CI. A plain `repo_gate.py` run also includes `composite-actions`, whose shell half needs Docker and either PyYAML or `uvx`, and whose schema half needs `uvx` or `pipx`. `uvx` alone is not enough for the shell half on a host that sets `UV_NO_CACHE` or uv's `no-cache = true`. There uv deletes the environment it resolved before the reader launches. Make PyYAML importable there or turn the setting off. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits. The pull request gate reads every line the branch changes against its base. A merge commit is diffed against `MERGE_HEAD` rather than `HEAD`, so the lines the merged-in parent brought are not read as added.
The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches. `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. `uv run --no-project --script .github/actions/prose-gate/prose_lint.py --skip-generated --diff HEAD -- <staged paths>` is diff-scoped, at about 0.15 seconds where its whole-tree sweep costs about 2.2. `python3 scripts/repo_gate.py --check eol` is repo-wide, since it reads `.gitattributes` against `.editorconfig` and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode. What reversed it is the diff scope rather than a change of preference. The gates needing Docker, and `sha-pin` which resolves a pin against the GitHub API, stay in CI. A plain `repo_gate.py` run also includes `composite-actions`, whose shell half needs Docker and either PyYAML or `uvx`, and whose schema half needs `uvx` or `pipx`. `uvx` alone is not enough for the shell half on a host that sets `UV_NO_CACHE` or uv's `no-cache = true`. There uv deletes the environment it resolved before the reader launches. Make PyYAML importable there or turn the setting off. The hook names the paths the commit stages, so an untracked file it does not commit cannot refuse it. It reads their working-tree content, so a partially staged file is judged on all of its edits. The pull request gate reads every line the branch changes against its base. A merge commit is diffed against `MERGE_HEAD` rather than `HEAD`, so the lines the merged-in parent brought are not read as added.

**Hosted here and reached, never carried.** These are not declared in [`spec/files.json`][files], so the audit does not expect a downstream repo to ship them, the same footing as `spec/audit.py`. That is the fleet model rather than an omission: a gate holding no per-repo content has one canonical implementation for the fleet. The prose and repository gate implementations live beside their composite actions under `.github/actions/`. That placement lets reusable workflows reach the implementation at their pinned hub commit through `$/.github/actions/` without checking out the hub. The same-named files in `scripts/` are thin local entry points that call those action-owned implementations. They let maintainers and hooks retain the established commands from a hub checkout without creating a second copy of either gate. A repository that cannot reach the hub reports a local check as not run rather than reconstructing it. That repository does so because a rebuilt gate encodes its author's reading of the rule and agrees with no other repository.

Expand Down Expand Up @@ -68,7 +68,7 @@ The default set also carries `issue-ref`. It reports an issue or pull request re

The default set also carries `sentence-length`. It reports a sentence over the word cap that `sentence_word_cap` in `prose-gate.json` sets, in Markdown prose, a comment, or a docstring. The tree reads zero over-cap sentences, so the rule gates like any other.

A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it. Leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable.
A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it. Leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable. `--skip-generated` drops a named file there instead, which is what a hook naming a commit's paths passes.

The reusable prose gate reads optional exclusions from `.github/prose-gate-excludes` in the repository it scans. Each non-empty line that does not start with `#` is one repository-relative path fragment passed to `--exclude`. Use the contract only for vendored paths whose upstream body the repository preserves. Repository-authored provenance files and patches stay outside the excluded path so the gate continues to read them.

Expand Down
Loading
Loading