diff --git a/.agents/skills/comment-and-doc-style/SKILL.md b/.agents/skills/comment-and-doc-style/SKILL.md index aced18d4..af574e92 100644 --- a/.agents/skills/comment-and-doc-style/SKILL.md +++ b/.agents/skills/comment-and-doc-style/SKILL.md @@ -224,8 +224,8 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. ## PR titles and commit messages -- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour - PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-Hour + PM2.5 Average Sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, explains *why* the change is being made when that is non-obvious, the diff already shows *what*. - **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No @@ -237,11 +237,11 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. *EPA-Corrected*, *24-Hour*). ```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project +Add Structured Logging Extensions to Library +Pin softprops/action-gh-release to Commit SHA +Drop net8.0 Multi-Targeting from Console Project Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README +Clarify devcontainer Setup Steps in README ``` ## Quantitative claims diff --git a/.agents/skills/comment-and-doc-style/references/line-endings.md b/.agents/skills/comment-and-doc-style/references/line-endings.md index bdf0846a..8337cbcf 100644 --- a/.agents/skills/comment-and-doc-style/references/line-endings.md +++ b/.agents/skills/comment-and-doc-style/references/line-endings.md @@ -71,9 +71,10 @@ tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` override, since the git pin alone is not enough there, `.gitattributes` governs git while the editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, -not just EOL: `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +not just EOL: `[/**]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value -that removes an inherited property). +that removes an inherited property, and `**` is needed rather than `*` so a nested file under the +directory is covered too, since `*` excludes `/` and only matches one path component). ## Editing discipline diff --git a/.agents/skills/dotnet-codestyle/references/conventions.md b/.agents/skills/dotnet-codestyle/references/conventions.md index 5eb48540..46897c81 100644 --- a/.agents/skills/dotnet-codestyle/references/conventions.md +++ b/.agents/skills/dotnet-codestyle/references/conventions.md @@ -122,5 +122,15 @@ parameters, return values, exceptions, and crefs. /// /// Thrown when is not a supported value. /// -public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} +public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) +{ + if (category is not ("motivational" or "humor")) + { + throw new ArgumentException($"Unsupported category: {category}", nameof(category)); + } + + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(1, cancellationToken); + return $"Quote for {category}"; +} ``` diff --git a/.agents/skills/dotnet-codestyle/references/project-config.md b/.agents/skills/dotnet-codestyle/references/project-config.md index 8f6e8388..42b8fd1c 100644 --- a/.agents/skills/dotnet-codestyle/references/project-config.md +++ b/.agents/skills/dotnet-codestyle/references/project-config.md @@ -15,3 +15,7 @@ ``` + +5. **Nullable and XML documentation**: `enable`, + `true` (see `references/conventions.md` + for the XML documentation format every public surface needs). diff --git a/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md index 597cd5f1..6d934c0e 100644 --- a/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.agents/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md @@ -35,7 +35,7 @@ Two traps, both learned the hard way: `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take `develop`'s side for the EOL-conflicted files (`git checkout --theirs `) **after confirming each is content-identical modulo EOL, or that `develop` is a strict superset** - (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into + (`diff <(git show ":2:" | tr -d '\r') <(git show ":3:" | tr -d '\r')`), then open that branch into `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). ## Why both rulesets omit "Require branches to be up to date before merging" diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index afb345af..f7a7451e 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -48,7 +48,7 @@ declaration, versioning, VS Code config), see `references/profiles.md`. | [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | | [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | | [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | -| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | +| [pytest][docs-link] | test runner (build profile only, lint-only uses `unittest`) | `pyproject.toml` `[tool.pytest.ini_options]` | **Type checking targets strongly typed, deterministic code.** pyright in strict mode is the default baseline on first-party code (a repo may instead run mypy in CI and keep pyright @@ -72,7 +72,9 @@ inherently consistent. ## Local development loop -From inside the Python project directory: +From inside a **build**-profile Python project directory. A **lint-only** Scripts profile has no +`uv.lock` to sync and no pytest to run, substitute `uvx` per tool and `unittest` per the Two +Profiles section above: ```sh uv sync # creates .venv, installs deps + dev group @@ -85,15 +87,18 @@ uv run pytest # run tests uv build # produce wheel + sdist in ./dist (published packages only) ``` -The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: -`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs -both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are -documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell -chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. A working local hook is -strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters -Locally" for what the hook must cover and what its absence means. +The **build**-profile Python clean-compile is `uv run ruff format` + `uv run ruff check` + the +repo's type checker: `uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both +where the repo runs both (see Type checking above). Run it, plus `uv run pytest`, before +committing. A **lint-only** profile's clean-compile substitutes its `uvx` and `unittest` +equivalents, per Two Profiles above, and has no such command to run before committing beyond +those. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, +no `&&` shell chaining, so it runs the same on any task shell) is in the hub +`vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative +backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` +framework from the canonical `catalog/snippets/pre-commit/.pre-commit-config.yaml`. See +GOVERNANCE.md "Running the Linters Locally" for what the hook must cover and what its absence +means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before @@ -142,9 +147,12 @@ For comments, docstrings, full type-hint rules, naming, imports, and all pattern ## Tests -`uv run pytest`. One test file per module (`test_.py`), fixtures over setup/teardown, -fakes over mocks. Test the docstring's contract, not implementation details. See -`references/testing.md` for the full conventions. +`uv run pytest` for a build profile, `unittest` for a lint-only Scripts profile (see Two Profiles +above). One test file per module (`test_.py`). A build profile prefers fixtures over +`unittest`'s `setUp`/`tearDown` lifecycle hooks. A lint-only profile uses those hooks directly, +since `unittest` has no fixture-injection mechanism of its own. Fakes over mocks either way. Test +the docstring's contract, not implementation details. See `references/testing.md` for the full +build-profile conventions, and `references/profiles.md` for the lint-only `unittest` conventions. ## Versioning @@ -159,10 +167,11 @@ Before pushing or opening a PR: - VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). -- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker - (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local - loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, - so the runner shell is irrelevant). +- The **build**-profile CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's + type checker (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as + the local loop above, run from the Python project directory (invoked as separate steps, not + `&&`-chained, so the runner shell is irrelevant). A **lint-only** profile's CI gate is its `uvx` + equivalents plus its `unittest` suite, per `references/profiles.md`. - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. diff --git a/.agents/skills/python-codestyle/references/testing.md b/.agents/skills/python-codestyle/references/testing.md index c19ff9d7..0dae3fd4 100644 --- a/.agents/skills/python-codestyle/references/testing.md +++ b/.agents/skills/python-codestyle/references/testing.md @@ -1,5 +1,9 @@ # Python Testing Conventions +This covers the **build** profile. A **lint-only** Scripts profile has no `uv.lock` and does not +use pytest, its testing conventions (`unittest`, `uvx coverage@latest run -m unittest discover`) +are in `references/profiles.md`. + Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. diff --git a/.agents/skills/resync-a-repo/SKILL.md b/.agents/skills/resync-a-repo/SKILL.md index e291fb66..019e45de 100644 --- a/.agents/skills/resync-a-repo/SKILL.md +++ b/.agents/skills/resync-a-repo/SKILL.md @@ -67,8 +67,9 @@ Preserve the evidence RESYNC.md section 2 requires, and do not leave the finding 4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, the artifact-name handoff, rather than copying bytes. 5. **Settings, rulesets, and secrets.** Run - `repo-config/configure.sh check / release|operational` from the hub at `main`, - then `apply` for what it reports, never from a carried copy. + `repo-config/configure.sh check "/" release` (substitute `operational` for an + operational repo) from the hub at `main`, then `apply` for what it reports, never from a + carried copy. 6. **Intent files last, and by hand,** since nothing mechanical judges these. Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, @@ -81,4 +82,5 @@ One focused pull request per drift class, branched from the target's `develop`, push to a protected branch and never a hand edit outside a pull request. Close the review loop, per the `pr-review-conduct` skill, before asking the maintainer for merge permission. The maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and -commit the report, done means measured, not applied. +commit the report once authorized, per `git-commit-conventions`, done means measured, not +applied. diff --git a/.agents/skills/skill-lifecycle/SKILL.md b/.agents/skills/skill-lifecycle/SKILL.md index 720c59ac..aa853916 100644 --- a/.agents/skills/skill-lifecycle/SKILL.md +++ b/.agents/skills/skill-lifecycle/SKILL.md @@ -28,7 +28,7 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim 3. **Author the body per the `comment-and-doc-style` skill**: LF (the repo default), present tense, ASCII tiers, no semicolon in prose. Name hub paths as plain code spans rather than repo-relative links, because an installed copy resolves no repo path, and say "from a hub checkout" for anything the reader must run. 4. **Split bulk into `references/`** when the source doc is large: the SKILL.md carries the summary and the binding rules, and each `references/*.md` carries one topic read on demand, the shape `comment-and-doc-style` uses. 5. **Apply the doc-packaging pattern below in the same change** when the skill packages a law doc or one of its sections. -6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then commit the source and both generated trees in one commit. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. +6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then, once authorized, commit the source and both generated trees in one commit, per `git-commit-conventions`. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. 7. **Record the surfacing**: annotate the `AGENTS.md` "Where the Rules Live" row when the skill packages a GOVERNANCE section, or its closing paragraph when the skill is new content, so the map stays the one place coverage is read from. 8. **Refresh the machines after merge**: re-run `python3 scripts/skills_install.py` per machine, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then every machine serves the previous skill set, which `--report` says. diff --git a/.agents/skills/standup-a-repo/SKILL.md b/.agents/skills/standup-a-repo/SKILL.md index 54c277a5..f9d2b4f5 100644 --- a/.agents/skills/standup-a-repo/SKILL.md +++ b/.agents/skills/standup-a-repo/SKILL.md @@ -74,9 +74,12 @@ maintainer can supply what section 0A lists. inventing a shape. 8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub - repository agree before running anything else here, then apply with - `repo-config/configure.sh apply owner/repo release|operational` from the hub at `main` and check with the same - command's `check` subcommand, never from a hand-built or carried copy. + repository agree before running anything else here, then run + `repo-config/configure.sh check owner/repo release` (substitute `operational` for an + operational repo) from the hub at `main`. A non-zero exit there means drift was found, not a + command failure. Review what it reports. Then run the same command's `apply` subcommand, which + idempotently reconciles the repo to the full committed configuration regardless of what `check` + reported, never from a hand-built or carried copy. 9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood up only when it passes for its type, or its residual deltas are tracked in diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 97ea0b63..ce2732c5 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -b945e66c274cb82a +5ab0e6a26d537def diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md index aced18d4..af574e92 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md @@ -224,8 +224,8 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. ## PR titles and commit messages -- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour - PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-Hour + PM2.5 Average Sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, explains *why* the change is being made when that is non-obvious, the diff already shows *what*. - **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No @@ -237,11 +237,11 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. *EPA-Corrected*, *24-Hour*). ```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project +Add Structured Logging Extensions to Library +Pin softprops/action-gh-release to Commit SHA +Drop net8.0 Multi-Targeting from Console Project Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README +Clarify devcontainer Setup Steps in README ``` ## Quantitative claims diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md index bdf0846a..8337cbcf 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/references/line-endings.md @@ -71,9 +71,10 @@ tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` override, since the git pin alone is not enough there, `.gitattributes` governs git while the editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, -not just EOL: `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +not just EOL: `[/**]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value -that removes an inherited property). +that removes an inherited property, and `**` is needed rather than `*` so a nested file under the +directory is covered too, since `*` excludes `/` and only matches one path component). ## Editing discipline diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/conventions.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/conventions.md index 5eb48540..46897c81 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/conventions.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/conventions.md @@ -122,5 +122,15 @@ parameters, return values, exceptions, and crefs. /// /// Thrown when is not a supported value. /// -public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} +public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) +{ + if (category is not ("motivational" or "humor")) + { + throw new ArgumentException($"Unsupported category: {category}", nameof(category)); + } + + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(1, cancellationToken); + return $"Quote for {category}"; +} ``` diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/project-config.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/project-config.md index 8f6e8388..42b8fd1c 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/project-config.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/project-config.md @@ -15,3 +15,7 @@ ``` + +5. **Nullable and XML documentation**: `enable`, + `true` (see `references/conventions.md` + for the XML documentation format every public surface needs). diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md index 597cd5f1..6d934c0e 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md @@ -35,7 +35,7 @@ Two traps, both learned the hard way: `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take `develop`'s side for the EOL-conflicted files (`git checkout --theirs `) **after confirming each is content-identical modulo EOL, or that `develop` is a strict superset** - (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into + (`diff <(git show ":2:" | tr -d '\r') <(git show ":3:" | tr -d '\r')`), then open that branch into `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). ## Why both rulesets omit "Require branches to be up to date before merging" diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index afb345af..f7a7451e 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -48,7 +48,7 @@ declaration, versioning, VS Code config), see `references/profiles.md`. | [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | | [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | | [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | -| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | +| [pytest][docs-link] | test runner (build profile only, lint-only uses `unittest`) | `pyproject.toml` `[tool.pytest.ini_options]` | **Type checking targets strongly typed, deterministic code.** pyright in strict mode is the default baseline on first-party code (a repo may instead run mypy in CI and keep pyright @@ -72,7 +72,9 @@ inherently consistent. ## Local development loop -From inside the Python project directory: +From inside a **build**-profile Python project directory. A **lint-only** Scripts profile has no +`uv.lock` to sync and no pytest to run, substitute `uvx` per tool and `unittest` per the Two +Profiles section above: ```sh uv sync # creates .venv, installs deps + dev group @@ -85,15 +87,18 @@ uv run pytest # run tests uv build # produce wheel + sdist in ./dist (published packages only) ``` -The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: -`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs -both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are -documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell -chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. A working local hook is -strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters -Locally" for what the hook must cover and what its absence means. +The **build**-profile Python clean-compile is `uv run ruff format` + `uv run ruff check` + the +repo's type checker: `uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both +where the repo runs both (see Type checking above). Run it, plus `uv run pytest`, before +committing. A **lint-only** profile's clean-compile substitutes its `uvx` and `unittest` +equivalents, per Two Profiles above, and has no such command to run before committing beyond +those. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, +no `&&` shell chaining, so it runs the same on any task shell) is in the hub +`vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative +backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` +framework from the canonical `catalog/snippets/pre-commit/.pre-commit-config.yaml`. See +GOVERNANCE.md "Running the Linters Locally" for what the hook must cover and what its absence +means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before @@ -142,9 +147,12 @@ For comments, docstrings, full type-hint rules, naming, imports, and all pattern ## Tests -`uv run pytest`. One test file per module (`test_.py`), fixtures over setup/teardown, -fakes over mocks. Test the docstring's contract, not implementation details. See -`references/testing.md` for the full conventions. +`uv run pytest` for a build profile, `unittest` for a lint-only Scripts profile (see Two Profiles +above). One test file per module (`test_.py`). A build profile prefers fixtures over +`unittest`'s `setUp`/`tearDown` lifecycle hooks. A lint-only profile uses those hooks directly, +since `unittest` has no fixture-injection mechanism of its own. Fakes over mocks either way. Test +the docstring's contract, not implementation details. See `references/testing.md` for the full +build-profile conventions, and `references/profiles.md` for the lint-only `unittest` conventions. ## Versioning @@ -159,10 +167,11 @@ Before pushing or opening a PR: - VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). -- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker - (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local - loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, - so the runner shell is irrelevant). +- The **build**-profile CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's + type checker (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as + the local loop above, run from the Python project directory (invoked as separate steps, not + `&&`-chained, so the runner shell is irrelevant). A **lint-only** profile's CI gate is its `uvx` + equivalents plus its `unittest` suite, per `references/profiles.md`. - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md index c19ff9d7..0dae3fd4 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md @@ -1,5 +1,9 @@ # Python Testing Conventions +This covers the **build** profile. A **lint-only** Scripts profile has no `uv.lock` and does not +use pytest, its testing conventions (`unittest`, `uvx coverage@latest run -m unittest discover`) +are in `references/profiles.md`. + Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. diff --git a/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md index e291fb66..019e45de 100644 --- a/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/resync-a-repo/SKILL.md @@ -67,8 +67,9 @@ Preserve the evidence RESYNC.md section 2 requires, and do not leave the finding 4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, the artifact-name handoff, rather than copying bytes. 5. **Settings, rulesets, and secrets.** Run - `repo-config/configure.sh check / release|operational` from the hub at `main`, - then `apply` for what it reports, never from a carried copy. + `repo-config/configure.sh check "/" release` (substitute `operational` for an + operational repo) from the hub at `main`, then `apply` for what it reports, never from a + carried copy. 6. **Intent files last, and by hand,** since nothing mechanical judges these. Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, @@ -81,4 +82,5 @@ One focused pull request per drift class, branched from the target's `develop`, push to a protected branch and never a hand edit outside a pull request. Close the review loop, per the `pr-review-conduct` skill, before asking the maintainer for merge permission. The maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and -commit the report, done means measured, not applied. +commit the report once authorized, per `git-commit-conventions`, done means measured, not +applied. diff --git a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md index 720c59ac..aa853916 100644 --- a/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/skill-lifecycle/SKILL.md @@ -28,7 +28,7 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim 3. **Author the body per the `comment-and-doc-style` skill**: LF (the repo default), present tense, ASCII tiers, no semicolon in prose. Name hub paths as plain code spans rather than repo-relative links, because an installed copy resolves no repo path, and say "from a hub checkout" for anything the reader must run. 4. **Split bulk into `references/`** when the source doc is large: the SKILL.md carries the summary and the binding rules, and each `references/*.md` carries one topic read on demand, the shape `comment-and-doc-style` uses. 5. **Apply the doc-packaging pattern below in the same change** when the skill packages a law doc or one of its sections. -6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then commit the source and both generated trees in one commit. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. +6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then, once authorized, commit the source and both generated trees in one commit, per `git-commit-conventions`. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. 7. **Record the surfacing**: annotate the `AGENTS.md` "Where the Rules Live" row when the skill packages a GOVERNANCE section, or its closing paragraph when the skill is new content, so the map stays the one place coverage is read from. 8. **Refresh the machines after merge**: re-run `python3 scripts/skills_install.py` per machine, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then every machine serves the previous skill set, which `--report` says. diff --git a/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md b/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md index 54c277a5..f9d2b4f5 100644 --- a/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/standup-a-repo/SKILL.md @@ -74,9 +74,12 @@ maintainer can supply what section 0A lists. inventing a shape. 8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub - repository agree before running anything else here, then apply with - `repo-config/configure.sh apply owner/repo release|operational` from the hub at `main` and check with the same - command's `check` subcommand, never from a hand-built or carried copy. + repository agree before running anything else here, then run + `repo-config/configure.sh check owner/repo release` (substitute `operational` for an + operational repo) from the hub at `main`. A non-zero exit there means drift was found, not a + command failure. Review what it reports. Then run the same command's `apply` subcommand, which + idempotently reconciles the repo to the full committed configuration regardless of what `check` + reported, never from a hand-built or carried copy. 9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood up only when it passes for its type, or its residual deltas are tracked in diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md index aced18d4..af574e92 100644 --- a/.github/skills/comment-and-doc-style/SKILL.md +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -224,8 +224,8 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. ## PR titles and commit messages -- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-hour - PM2.5 average sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-Hour + PM2.5 Average Sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, explains *why* the change is being made when that is non-obvious, the diff already shows *what*. - **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No @@ -237,11 +237,11 @@ hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. *EPA-Corrected*, *24-Hour*). ```text -Add structured logging extensions to library -Pin softprops/action-gh-release to commit SHA -Drop net8.0 multi-targeting from console project +Add Structured Logging Extensions to Library +Pin softprops/action-gh-release to Commit SHA +Drop net8.0 Multi-Targeting from Console Project Bump xunit.v3 from 3.2.2 to 3.3.0 -Clarify devcontainer setup steps in README +Clarify devcontainer Setup Steps in README ``` ## Quantitative claims diff --git a/.github/skills/comment-and-doc-style/references/line-endings.md b/.github/skills/comment-and-doc-style/references/line-endings.md index bdf0846a..8337cbcf 100644 --- a/.github/skills/comment-and-doc-style/references/line-endings.md +++ b/.github/skills/comment-and-doc-style/references/line-endings.md @@ -71,9 +71,10 @@ tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` override, since the git pin alone is not enough there, `.gitattributes` governs git while the editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, -not just EOL: `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +not just EOL: `[/**]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value -that removes an inherited property). +that removes an inherited property, and `**` is needed rather than `*` so a nested file under the +directory is covered too, since `*` excludes `/` and only matches one path component). ## Editing discipline diff --git a/.github/skills/dotnet-codestyle/references/conventions.md b/.github/skills/dotnet-codestyle/references/conventions.md index 5eb48540..46897c81 100644 --- a/.github/skills/dotnet-codestyle/references/conventions.md +++ b/.github/skills/dotnet-codestyle/references/conventions.md @@ -122,5 +122,15 @@ parameters, return values, exceptions, and crefs. /// /// Thrown when is not a supported value. /// -public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) {} +public async Task GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) +{ + if (category is not ("motivational" or "humor")) + { + throw new ArgumentException($"Unsupported category: {category}", nameof(category)); + } + + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(1, cancellationToken); + return $"Quote for {category}"; +} ``` diff --git a/.github/skills/dotnet-codestyle/references/project-config.md b/.github/skills/dotnet-codestyle/references/project-config.md index 8f6e8388..42b8fd1c 100644 --- a/.github/skills/dotnet-codestyle/references/project-config.md +++ b/.github/skills/dotnet-codestyle/references/project-config.md @@ -15,3 +15,7 @@ ``` + +5. **Nullable and XML documentation**: `enable`, + `true` (see `references/conventions.md` + for the XML documentation format every public surface needs). diff --git a/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md index 597cd5f1..6d934c0e 100644 --- a/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md +++ b/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md @@ -35,7 +35,7 @@ Two traps, both learned the hard way: `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take `develop`'s side for the EOL-conflicted files (`git checkout --theirs `) **after confirming each is content-identical modulo EOL, or that `develop` is a strict superset** - (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | tr -d '\r')`), then open that branch into + (`diff <(git show ":2:" | tr -d '\r') <(git show ":3:" | tr -d '\r')`), then open that branch into `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). ## Why both rulesets omit "Require branches to be up to date before merging" diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index afb345af..f7a7451e 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -48,7 +48,7 @@ declaration, versioning, VS Code config), see `references/profiles.md`. | [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | | [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | | [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | -| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | +| [pytest][docs-link] | test runner (build profile only, lint-only uses `unittest`) | `pyproject.toml` `[tool.pytest.ini_options]` | **Type checking targets strongly typed, deterministic code.** pyright in strict mode is the default baseline on first-party code (a repo may instead run mypy in CI and keep pyright @@ -72,7 +72,9 @@ inherently consistent. ## Local development loop -From inside the Python project directory: +From inside a **build**-profile Python project directory. A **lint-only** Scripts profile has no +`uv.lock` to sync and no pytest to run, substitute `uvx` per tool and `unittest` per the Two +Profiles section above: ```sh uv sync # creates .venv, installs deps + dev group @@ -85,15 +87,18 @@ uv run pytest # run tests uv build # produce wheel + sdist in ./dist (published packages only) ``` -The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the repo's type checker: -`uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both where the repo runs -both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are -documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell -chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. A working local hook is -strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters -Locally" for what the hook must cover and what its absence means. +The **build**-profile Python clean-compile is `uv run ruff format` + `uv run ruff check` + the +repo's type checker: `uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both +where the repo runs both (see Type checking above). Run it, plus `uv run pytest`, before +committing. A **lint-only** profile's clean-compile substitutes its `uvx` and `unittest` +equivalents, per Two Profiles above, and has no such command to run before committing beyond +those. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, +no `&&` shell chaining, so it runs the same on any task shell) is in the hub +`vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative +backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` +framework from the canonical `catalog/snippets/pre-commit/.pre-commit-config.yaml`. See +GOVERNANCE.md "Running the Linters Locally" for what the hook must cover and what its absence +means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before @@ -142,9 +147,12 @@ For comments, docstrings, full type-hint rules, naming, imports, and all pattern ## Tests -`uv run pytest`. One test file per module (`test_.py`), fixtures over setup/teardown, -fakes over mocks. Test the docstring's contract, not implementation details. See -`references/testing.md` for the full conventions. +`uv run pytest` for a build profile, `unittest` for a lint-only Scripts profile (see Two Profiles +above). One test file per module (`test_.py`). A build profile prefers fixtures over +`unittest`'s `setUp`/`tearDown` lifecycle hooks. A lint-only profile uses those hooks directly, +since `unittest` has no fixture-injection mechanism of its own. Fakes over mocks either way. Test +the docstring's contract, not implementation details. See `references/testing.md` for the full +build-profile conventions, and `references/profiles.md` for the lint-only `unittest` conventions. ## Versioning @@ -159,10 +167,11 @@ Before pushing or opening a PR: - VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). -- The CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's type checker - (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as the local - loop above, run from the Python project directory (invoked as separate steps, not `&&`-chained, - so the runner shell is irrelevant). +- The **build**-profile CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's + type checker (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as + the local loop above, run from the Python project directory (invoked as separate steps, not + `&&`-chained, so the runner shell is irrelevant). A **lint-only** profile's CI gate is its `uvx` + equivalents plus its `unittest` suite, per `references/profiles.md`. - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. diff --git a/.github/skills/python-codestyle/references/testing.md b/.github/skills/python-codestyle/references/testing.md index c19ff9d7..0dae3fd4 100644 --- a/.github/skills/python-codestyle/references/testing.md +++ b/.github/skills/python-codestyle/references/testing.md @@ -1,5 +1,9 @@ # Python Testing Conventions +This covers the **build** profile. A **lint-only** Scripts profile has no `uv.lock` and does not +use pytest, its testing conventions (`unittest`, `uvx coverage@latest run -m unittest discover`) +are in `references/profiles.md`. + Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. diff --git a/.github/skills/resync-a-repo/SKILL.md b/.github/skills/resync-a-repo/SKILL.md index e291fb66..019e45de 100644 --- a/.github/skills/resync-a-repo/SKILL.md +++ b/.github/skills/resync-a-repo/SKILL.md @@ -67,8 +67,9 @@ Preserve the evidence RESYNC.md section 2 requires, and do not leave the finding 4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, the artifact-name handoff, rather than copying bytes. 5. **Settings, rulesets, and secrets.** Run - `repo-config/configure.sh check / release|operational` from the hub at `main`, - then `apply` for what it reports, never from a carried copy. + `repo-config/configure.sh check "/" release` (substitute `operational` for an + operational repo) from the hub at `main`, then `apply` for what it reports, never from a + carried copy. 6. **Intent files last, and by hand,** since nothing mechanical judges these. Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, @@ -81,4 +82,5 @@ One focused pull request per drift class, branched from the target's `develop`, push to a protected branch and never a hand edit outside a pull request. Close the review loop, per the `pr-review-conduct` skill, before asking the maintainer for merge permission. The maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and -commit the report, done means measured, not applied. +commit the report once authorized, per `git-commit-conventions`, done means measured, not +applied. diff --git a/.github/skills/skill-lifecycle/SKILL.md b/.github/skills/skill-lifecycle/SKILL.md index 720c59ac..aa853916 100644 --- a/.github/skills/skill-lifecycle/SKILL.md +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -28,7 +28,7 @@ A skill surfaces at a trigger moment. A rule that binds every action all the tim 3. **Author the body per the `comment-and-doc-style` skill**: LF (the repo default), present tense, ASCII tiers, no semicolon in prose. Name hub paths as plain code spans rather than repo-relative links, because an installed copy resolves no repo path, and say "from a hub checkout" for anything the reader must run. 4. **Split bulk into `references/`** when the source doc is large: the SKILL.md carries the summary and the binding rules, and each `references/*.md` carries one topic read on demand, the shape `comment-and-doc-style` uses. 5. **Apply the doc-packaging pattern below in the same change** when the skill packages a law doc or one of its sections. -6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then commit the source and both generated trees in one commit. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. +6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then, once authorized, commit the source and both generated trees in one commit, per `git-commit-conventions`. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. 7. **Record the surfacing**: annotate the `AGENTS.md` "Where the Rules Live" row when the skill packages a GOVERNANCE section, or its closing paragraph when the skill is new content, so the map stays the one place coverage is read from. 8. **Refresh the machines after merge**: re-run `python3 scripts/skills_install.py` per machine, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then every machine serves the previous skill set, which `--report` says. diff --git a/.github/skills/standup-a-repo/SKILL.md b/.github/skills/standup-a-repo/SKILL.md index 54c277a5..f9d2b4f5 100644 --- a/.github/skills/standup-a-repo/SKILL.md +++ b/.github/skills/standup-a-repo/SKILL.md @@ -74,9 +74,12 @@ maintainer can supply what section 0A lists. inventing a shape. 8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub - repository agree before running anything else here, then apply with - `repo-config/configure.sh apply owner/repo release|operational` from the hub at `main` and check with the same - command's `check` subcommand, never from a hand-built or carried copy. + repository agree before running anything else here, then run + `repo-config/configure.sh check owner/repo release` (substitute `operational` for an + operational repo) from the hub at `main`. A non-zero exit there means drift was found, not a + command failure. Review what it reports. Then run the same command's `apply` subcommand, which + idempotently reconciles the repo to the full committed configuration regardless of what `check` + reported, never from a hand-built or carried copy. 9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood up only when it passes for its type, or its residual deltas are tracked in diff --git a/AUDIT.md b/AUDIT.md index 7be358cc..2e467be1 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -116,6 +116,8 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: # then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry ``` +- **Dependabot on self-hosted runners (account setting)** - a repo whose `dependabot-updates` or `update-graph` workflow runs are all `cancelled` with zero steps has this problem. The account-wide toggle at `https://github.com/settings/security_analysis`, `Dependabot on self-hosted runners`, routes Dependabot's own update jobs to a self-hosted runner pool. With none registered on the account, those jobs queue for up to 24 hours, then get cancelled. The cancelled-with-zero-steps pattern above is the only Actions-API-visible signal, not an explicit cause, and ordinary CI is unaffected. The account-setting root cause surfaces only as a `Self-hosted runner unavailable` message on the repo's own Dependabot page. GitHub never routes a public repo through this setting, so `ProjectTemplate` itself cannot show the symptom (ptr727/ProjectTemplate#1015). Detection stops there, like the rest of this audit. Remediation is a separate, manual action. Confirm the toggle, and `Automatically enable for new repositories` beside it, are both off, or register a matching self-hosted runner instead of disabling it. Disabling the toggle does not rerun jobs already queued. Each affected repo still needs its own manual `Check for Updates` click on its own Dependabot page. + ## 7. Verdict Model Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: diff --git a/STANDUP.md b/STANDUP.md index ce3feda8..6af8e687 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -206,6 +206,8 @@ Each is step 0A's escalation rather than something to work around. Run `repo-config/configure.sh apply owner/repo release|operational` from a hub checkout at `main`, naming the repo being stood up and its model, to apply the fleet settings, Dependabot security features, and two rulesets idempotently (import the JSON, never hand-build it, per [`docs/repo-config.md`][repo-config-doc]). Then run `repo-config/configure.sh check owner/repo release|operational` from the same checkout. Pass the model explicitly because the repository is outside the registry during this step. Configure every required secret per [`spec/secrets.json`][secrets] (the registry `requiredSecrets[]` list plus the implicit baseline) in the right store(s), meaning Actions plus Dependabot where the mechanism needs it, and confirm no forbidden secret is present. The required check binds by name (`Check pull request workflow status job`) and turns green only after the PR workflow has run once, which is why this step follows step 3 rather than preceding it. A ruleset requiring a name no run has ever reported leaves the first pull request waiting on a status nothing produces, and on an operational repo the `develop -> main` promotion is a pull request too, so the same wait applies there. +For a **private** repo, confirm the account-wide toggle at `https://github.com/settings/security_analysis`, `Dependabot on self-hosted runners`, is off, along with `Automatically enable for new repositories` beside it. If self-hosted routing is wanted instead, register a matching self-hosted runner rather than disabling the toggle. Left on with no self-hosted runner registered on the account, Dependabot's own update jobs queue for up to 24 hours, then get cancelled. That cancelled-with-zero-steps pattern is the only Actions-API-visible signal, not an explicit cause, and ordinary CI is unaffected. The account-setting root cause surfaces only as a `Self-hosted runner unavailable` message on the repo's own Dependabot page. GitHub never routes a public repo through this setting, so a public standup is unaffected (ptr727/ProjectTemplate#1015). A repo standing up from a **partial state** may already carry queued or cancelled jobs from before this check ran. Fixing the toggle does not rerun those. A manual `Check for Updates` click on the repo's own Dependabot page does. + ## 5. Verify: Run the Audit Run [`AUDIT.md`][audit] end to end. The repo is stood up only when it is **operational** (every applicable check passes) or its residual deltas are tracked in `reports//audit.md` plus an issue. Converge any drift through a Copilot-reviewed target PR ([`AUDIT.md`][audit] section 10), and the maintainer merges. A repo left partially set up and unrecorded is the exact failure this procedure exists to prevent. diff --git a/reports/divergences.md b/reports/divergences.md index 4e2eebed..ca3ab12f 100644 --- a/reports/divergences.md +++ b/reports/divergences.md @@ -10,7 +10,7 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur ### investigate -- **pyproject.toml** (manifest gap, carried by Financial-Modeling, aiopurpleair, homeassistant-purpleair) - The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent. +- **pyproject.toml** (manifest gap, carried by Financial-Modeling, aiopurpleair, homeassistant-purpleair) (tracking: ptr727/ProjectTemplate#669) - The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the Python repos carry an equivalent. reports/divergences.md already shows the carriers. The decision is tracked in ptr727/ProjectTemplate#669, so the remaining call is the fleet-wide new-findings tradeoff, which stays the maintainer's. ### retire @@ -51,12 +51,12 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. - **AGENTS.md > Context and Delegation Discipline** (2): PhotoCleaner, PlexCleaner -- **AGENTS.md > Where the Rules Live** (6): Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, PhotoCleaner, PlexCleaner +- **AGENTS.md > Where the Rules Live** (7): Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PhotoCleaner, PlexCleaner - **GOVERNANCE.md > Repository Boundaries and Write Safety** (2): PhotoCleaner, PlexCleaner - **GOVERNANCE.md > Operational Repositories** (2): PhotoCleaner, PlexCleaner - **GOVERNANCE.md > Hub-Hosted Tooling** (2): PhotoCleaner, PlexCleaner - **GOVERNANCE.md > PR Review Etiquette** (5): ESPHome-Config, Financial-Modeling, HomeAssistant-Config, PhotoCleaner, PlexCleaner -- **GOVERNANCE.md > Workflow YAML Conventions** (6): Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, PhotoCleaner, PlexCleaner +- **GOVERNANCE.md > Workflow YAML Conventions** (7): Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PhotoCleaner, PlexCleaner - **GOVERNANCE.md > Repository Details** (5): ESPHome-Config, Financial-Modeling, HomeAssistant-Config, PhotoCleaner, PlexCleaner - **.markdownlint-cli2.jsonc** (11): DevKitCIoT, ESPHome-NonRoot, HolidayLights, KiCadLibrary, LanguageTags, MediaTools, NxWitness, Utilities, VSCode-Server-DotNetCore, Vantage-Config, homeassistant-purpleair diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md index fd6b03be..6759cd42 100644 --- a/reports/workflow-reuse.md +++ b/reports/workflow-reuse.md @@ -1,12 +1,12 @@ # Fleet workflow reuse report -Generated by `python3 spec/workflow_reuse.py --report` at hub `76f15b3` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. +Generated by `python3 spec/workflow_reuse.py --report` at hub `5ce0374` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. ## Fleet Total -- **104 workflow files, 10,358 lines** across 20 downstream repos, 96 of them named for a hub canonical. No workflow at all in EspDinIoT. -- **3,654 lines (35%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. -- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 3.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. +- **101 workflow files, 9,592 lines** across 20 downstream repos, 92 of them named for a hub canonical. No workflow at all in EspDinIoT. +- **3,513 lines (37%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. +- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 19.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. ## Per Workflow @@ -14,16 +14,15 @@ Downstream copies of each hub canonical. A variant is a cluster of copies each a | File | Copies | Lines | Identical to hub | Variants | Callers | | --- | --- | --- | --- | --- | --- | -| `build-release-task.yml` | 9 | 1,709 | 891 | 6 | 0 | -| `test-pull-request.yml` | 20 | 1,661 | 497 | 14 | 1 | -| `merge-bot-pull-request.yml` | 16 | 1,626 | 203 | 8 | 1 | -| `publish-release.yml` | 17 | 1,374 | 416 | 13 | 1 | -| `validate-task.yml` | 13 | 1,214 | 442 | 11 | 0 | +| `build-release-task.yml` | 9 | 1,709 | 827 | 6 | 0 | +| `test-pull-request.yml` | 20 | 1,615 | 501 | 14 | 6 | +| `merge-bot-pull-request.yml` | 17 | 1,401 | 276 | 7 | 6 | +| `publish-release.yml` | 17 | 1,335 | 506 | 15 | 6 | +| `validate-task.yml` | 9 | 891 | 323 | 7 | 0 | | `build-docker-task.yml` | 4 | 512 | 266 | 4 | 0 | | `get-version-task.yml` | 7 | 439 | 342 | 4 | 0 | -| `publish-plan-task.yml` | 3 | 252 | 207 | 1 | 0 | -| `deploy-site-task.yml` | 1 | 191 | 113 | 1 | 0 | -| `run-codegen-pull-request-task.yml` | 2 | 161 | 130 | 1 | 0 | +| `publish-plan-task.yml` | 3 | 252 | 198 | 1 | 0 | +| `run-codegen-pull-request-task.yml` | 2 | 161 | 127 | 1 | 0 | | `check-upstream-version-task.yml` | 1 | 133 | 93 | 1 | 0 | | `run-periodic-codegen-pull-request.yml` | 2 | 48 | 29 | 2 | 0 | | `publish-docker-readme-task.yml` | 1 | 34 | 25 | 1 | 0 | @@ -40,12 +39,12 @@ Each variant names the repos whose copies cluster together, so a hub task's inpu - 1: aiopurpleair - 1: homeassistant-purpleair - `test-pull-request.yml` - - 2: AudioCleaner, Financial-Modeling + - 1: AudioCleaner - 1: Blog - 2: DevKitCIoT, HolidayLights - 1: ESPHome-Config - 1: ESPHome-NonRoot - - 1: HomeAssistant-Config + - 2: Financial-Modeling, HomeAssistant-Config - 1: HomeAutomation-Config - 1: KiCadLibrary - 4: LanguageTags, MediaTools, Utilities, aiopurpleair @@ -55,19 +54,19 @@ Each variant names the repos whose copies cluster together, so a hub task's inpu - 1: Vantage-Config - 1: homeassistant-purpleair - `merge-bot-pull-request.yml` - - 1: Blog - - 6: ESPHome-Config, HomeAssistant-Config, HomeAutomation-Config, PlexCleaner, Utilities, Vantage-Config + - 6: Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PhotoCleaner - 3: ESPHome-NonRoot, NxWitness, homeassistant-purpleair - 1: KiCadLibrary - 1: LanguageTags - 2: MediaTools, aiopurpleair - - 1: PhotoCleaner + - 3: PlexCleaner, Utilities, Vantage-Config - 1: VSCode-Server-DotNetCore - `publish-release.yml` - 1: Blog - - 2: ESPHome-Config, Vantage-Config + - 1: ESPHome-Config - 1: ESPHome-NonRoot - - 3: Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config + - 1: Financial-Modeling + - 2: HomeAssistant-Config, HomeAutomation-Config - 1: KiCadLibrary - 2: LanguageTags, MediaTools - 1: NxWitness @@ -75,15 +74,12 @@ Each variant names the repos whose copies cluster together, so a hub task's inpu - 1: PlexCleaner - 1: Utilities - 1: VSCode-Server-DotNetCore + - 1: Vantage-Config - 1: aiopurpleair - 1: homeassistant-purpleair - `validate-task.yml` - 3: AudioCleaner, MediaTools, Utilities - - 1: Blog - 1: ESPHome-NonRoot - - 1: Financial-Modeling - - 1: HomeAssistant-Config - - 1: HomeAutomation-Config - 1: LanguageTags - 1: NxWitness - 1: PlexCleaner @@ -101,8 +97,6 @@ Each variant names the repos whose copies cluster together, so a hub task's inpu - 1: homeassistant-purpleair - `publish-plan-task.yml` - 3: ESPHome-NonRoot, NxWitness, Utilities -- `deploy-site-task.yml` - - 1: Blog - `run-codegen-pull-request-task.yml` - 2: LanguageTags, NxWitness - `check-upstream-version-task.yml` @@ -117,34 +111,35 @@ Each variant names the repos whose copies cluster together, so a hub task's inpu | Repo | Files | Lines | Identical to hub | Callers | Repo-local files | | --- | --- | --- | --- | --- | --- | -| AudioCleaner | 2 | 134 | 58 | 0 | - | -| Blog | 6 | 560 | 229 | 0 | `deploy-site.yml` | +| AudioCleaner | 2 | 134 | 57 | 0 | - | +| Blog | 4 | 220 | 97 | 4 | `deploy-site.yml` | | DevKitCIoT | 1 | 58 | 25 | 0 | - | -| ESPHome-Config | 3 | 450 | 61 | 0 | - | -| ESPHome-NonRoot | 11 | 1,154 | 451 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | -| Financial-Modeling | 3 | 233 | 85 | 0 | - | +| ESPHome-Config | 3 | 327 | 113 | 3 | - | +| ESPHome-NonRoot | 11 | 1,154 | 457 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | +| Financial-Modeling | 3 | 137 | 77 | 3 | - | | HolidayLights | 1 | 53 | 25 | 0 | - | -| HomeAssistant-Config | 4 | 247 | 89 | 0 | - | -| HomeAutomation-Config | 4 | 248 | 87 | 0 | - | -| KiCadLibrary | 6 | 772 | 198 | 0 | `build-datebadge-task.yml` | -| LanguageTags | 7 | 724 | 305 | 0 | - | -| MediaTools | 5 | 530 | 212 | 0 | - | -| NxWitness | 10 | 1,102 | 378 | 0 | `build-base-images-task.yml` | -| PhotoCleaner | 3 | 203 | 86 | 3 | - | -| PlexCleaner | 8 | 805 | 356 | 0 | `build-executable-task.yml` | -| Utilities | 6 | 621 | 294 | 0 | - | +| HomeAssistant-Config | 3 | 124 | 98 | 3 | - | +| HomeAutomation-Config | 4 | 164 | 99 | 3 | `test-homelab-runner.yml` | +| KiCadLibrary | 6 | 772 | 201 | 0 | `build-datebadge-task.yml` | +| LanguageTags | 7 | 724 | 280 | 0 | - | +| MediaTools | 5 | 530 | 188 | 0 | - | +| NxWitness | 10 | 1,102 | 375 | 0 | `build-base-images-task.yml` | +| PhotoCleaner | 3 | 203 | 85 | 3 | - | +| PlexCleaner | 8 | 805 | 362 | 0 | `build-executable-task.yml` | +| Utilities | 6 | 621 | 266 | 0 | - | | VSCode-Server-DotNetCore | 8 | 563 | 294 | 0 | - | | Vantage-Config | 3 | 229 | 59 | 0 | - | -| aiopurpleair | 6 | 611 | 222 | 0 | - | -| homeassistant-purpleair | 7 | 1,061 | 140 | 0 | `check-ha-version.yml`, `test-release-task.yml` | +| aiopurpleair | 6 | 611 | 220 | 0 | - | +| homeassistant-purpleair | 7 | 1,061 | 135 | 0 | `check-ha-version.yml`, `test-release-task.yml` | ## Repo-Local Workflows A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which. -- **Blog** `deploy-site.yml` (55 lines) +- **Blog** `deploy-site.yml` (70 lines) - **ESPHome-NonRoot** `check-upstream-dependency.yml` (111 lines) - **ESPHome-NonRoot** `check-upstream-version.yml` (41 lines) +- **HomeAutomation-Config** `test-homelab-runner.yml` (43 lines) - **KiCadLibrary** `build-datebadge-task.yml` (37 lines) - **NxWitness** `build-base-images-task.yml` (89 lines) - **PlexCleaner** `build-executable-task.yml` (107 lines) diff --git a/spec/audit.py b/spec/audit.py index 030d46e9..34f1d064 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -32,6 +32,7 @@ import hashlib import itertools import json +import locale import pathlib import re import subprocess @@ -81,21 +82,53 @@ def load(rel): @functools.cache -def hub_tracked(): - """The hub's own git-tracked paths, which is what "hub-side" means for the hub-only comparison. - - git ls-files rather than a filesystem walk, since a walk picks up __pycache__ and a local .venv and - would make the result depend on working-tree state. - A non-zero exit raises rather than returning an empty set, which would read as "the hub tracks nothing" - and silently clear every hub-only finding. +def hub_tracked(rev=None): + """The hub's own tracked paths at the resolved `main` commit, which is what "hub-side" means + for the hub-only comparison. + + `git ls-tree -r` at that commit rather than `git ls-files` against ROOT's checked-out index + (a filesystem walk is avoided too, since it would pick up __pycache__ and a local .venv and + make the result depend on working-tree state), so a path present on `main` but absent on + `develop`, or the reverse, is not silently missed or falsely added + (ptr727/ProjectTemplate#1017 review). Filtered to regular-file modes (100644, 100755) the same + way `_git_revisions()` is: a directory, a symlink, or a submodule gitlink has no file content + to compare, and every caller here assumes a plain file at each returned path. `-z` NUL-delimits + the output so an unusual path is not C-quoted, which would otherwise return an escaped string + that matches nothing a caller compares it against. + + `rev` defaults to `_hub_main_rev()`. The --selftest fixture passes an explicit `rev` to check + against ROOT's own real tracked files without a network fetch, keeping the offline engine + self-test offline. + + A non-zero exit raises rather than returning an empty set, which would read as "the hub tracks + nothing" and silently clear every hub-only finding. """ - r = subprocess.run(["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=False) + walk_rev = _hub_main_rev() if rev is None else rev + # No text=True: -z's pathnames are raw bytes, and locale decoding here would crash on an invalid byte before the NUL-split below ever runs. + # Decode each record with a fixed utf-8/surrogateescape policy instead of os.fsdecode(), whose error handler is platform-dependent (surrogatepass on Windows) and still crashes on an arbitrary invalid byte there. + r = subprocess.run( + ["git", "ls-tree", "-r", "-z", walk_rev], + cwd=ROOT, + capture_output=True, + check=False, + ) if r.returncode != 0: - raise RuntimeError(f"git ls-files failed in {ROOT}: {r.stderr.strip() or 'non-zero exit'}") - return frozenset(r.stdout.splitlines()) + stderr = r.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"git ls-tree -r {walk_rev} failed in {ROOT}: {stderr or 'non-zero exit'}" + ) + paths = set() + for record in r.stdout.split(b"\0"): + if not record: + continue + meta, _, path = record.partition(b"\t") + mode = meta.split(None, 1)[0] if meta else None + if mode in (b"100644", b"100755"): + paths.add(path.decode("utf-8", errors="surrogateescape")) + return frozenset(paths) -def hub_only_paths(spec): +def hub_only_paths(spec, rev=None): """Hub-tracked paths the manifest does not declare, so a downstream copy is hub-hosted content rather than a carry. This is the deletion detector: the manifest says what a repo carries, so a file the hub tracks and the @@ -109,6 +142,8 @@ def hub_only_paths(spec): hooks at .husky/pre-commit. So only a `retire` disposition in spec/divergences.json asserts a deletion, and an untriaged hit asks for the file to be read. + + `rev` is passed through to `hub_tracked()`. See its docstring. """ declared = {e["path"] for e in spec["files"]["baseline"]} tree_paths = set() @@ -116,11 +151,11 @@ def hub_only_paths(spec): root = declaration["source"].rstrip("/") + "/" tree_paths.update( path - for path in hub_tracked() + for path in hub_tracked(rev) if path.startswith(root) and tree_path_included(path.removeprefix(root), declaration["include"]) ) - return hub_tracked() - declared - tree_paths + return hub_tracked(rev) - declared - tree_paths def gap_dispositions(spec): @@ -162,14 +197,32 @@ def repo_tree(slug, ground_head): return None if entries is None else set(entries) -def git_blob_sha(content): - header = f"blob {len(content)}\0".encode() - return hashlib.sha1(header + content).hexdigest() - - @functools.cache def canonical_blob_sha(path): - return git_blob_sha((ROOT / path).read_bytes()) + """The hub's git blob identity for path, from the same resolved `main` commit + `_git_revisions()` and `_hub_main_rev()` walk, not from ROOT's checked-out working tree, + which is not necessarily `main` (ptr727/ProjectTemplate#1017 review). Raises OSError, matching + a filesystem read's own contract, when path is absent from that commit or is not a regular + file there. + """ + rev = _hub_main_rev() + # Read via git ls-tree, not git rev-parse :, which resolves a directory to a tree object id just as readily as a file to a blob one, silently breaking the regular-file promise above. + result = subprocess.run( + ["git", "ls-tree", rev, "--", path], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise OSError(f"{path} is unreadable from the hub's main at {rev}: {result.stderr.strip()}") + entry = result.stdout.strip() + if not entry: + raise OSError(f"{path} is absent from the hub's main at {rev}") + mode, _, sha = entry.partition("\t")[0].split() + if mode not in ("100644", "100755"): + raise OSError(f"{path} is not a regular file at {rev} (mode {mode})") + return sha def tree_path_included(path, patterns): @@ -1716,7 +1769,40 @@ def classify_verbatim(down_text, canon_text, past_texts): @functools.cache -def _git_revisions(rel_path): +def _hub_main_rev(): + """The hub's own `main`, fetched fresh from `origin` and resolved to a commit SHA. + + Reached as a checkout of one's own, fetched immediately before use, per AGENTS.md and + ptr727/ProjectTemplate#1017, rather than trusting ROOT's checked-out branch. `git fetch` + writes into ROOT's own object database, so a separate clone is not needed. Resolved to a + concrete SHA right away, not the mutable `FETCH_HEAD` pointer, so a later fetch elsewhere in + the process cannot move it mid-run. + + Cached: one fetch and resolve per run, reused for every rel_path read. + """ + fetch = subprocess.run( + ["git", "fetch", "-q", "origin", "main"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if fetch.returncode != 0: + raise RuntimeError(f"git fetch origin main failed in {ROOT}: {fetch.stderr.strip()}") + resolved = subprocess.run( + ["git", "rev-parse", "FETCH_HEAD"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if resolved.returncode != 0 or not resolved.stdout.strip(): + raise RuntimeError(f"git rev-parse FETCH_HEAD failed in {ROOT}: {resolved.stderr.strip()}") + return resolved.stdout.strip() + + +@functools.cache +def _git_revisions(rel_path, rev=None): """Every commit that touched rel_path in the hub's history, newest first, as (date, sha, text). `text` is None where rel_path has no file content at this revision: absent (deleted, checked @@ -1728,9 +1814,16 @@ def _git_revisions(rel_path): permission or encoding fluke, a corrupt object) raises instead of folding into the same None, so a real command fault cannot pass as an ordinary absence. Cached because one canonical's history is read once per fidelity/staleness check, then reused for every audited repo's copy. + + `rev` names the git revision walked. It defaults to the hub's own `main` via + `_hub_main_rev()` (ptr727/ProjectTemplate#1017) rather than the implicit HEAD of whatever + branch ROOT has checked out. The --selftest fixtures pass an explicit `rev` to exercise a + plain local branch in a throwaway repo with no `origin` to fetch, keeping the offline engine + self-test offline. """ + walk_rev = _hub_main_rev() if rev is None else rev r = subprocess.run( - ["git", "log", "--format=%cI %H", "--", rel_path], + ["git", "log", "--format=%cI %H", walk_rev, "--", rel_path], cwd=ROOT, capture_output=True, text=True, @@ -1788,6 +1881,18 @@ def git_file_history(rel_path): return [text for _, _, text in _git_revisions(rel_path) if text is not None] +def canonical_current_text(rel_path): + """The hub's current canonical content of rel_path, from the same `_git_revisions()` call + `git_file_history()` and `hub_last_change()` already make, so "current" and "history" can + never disagree about which commit they read (ptr727/ProjectTemplate#1017 review: reading + "current" from ROOT's working tree while history walked the resolved `main` SHA let a copy + that matches today's main misclassify as stale against its own most recent history entry). + None if rel_path has no history at `main`, or its newest revision has no file content there. + """ + revisions = _git_revisions(rel_path) + return revisions[0][2] if revisions else None + + def _last_effective_change(revisions): """The (date, sha) of the newest revision in `revisions` (newest-first (date, sha, text) triples for one file) whose content differs from its predecessor after normalize(), or the @@ -1818,10 +1923,17 @@ def _last_effective_change(revisions): @functools.cache -def git_blob_in_file_history(rel_path, blob_sha): - """Whether a blob occurred in a path's hub history.""" +def git_blob_in_file_history(rel_path, blob_sha, rev=None): + """Whether a blob occurred in a path's hub history. + + `rev` defaults to the hub's own `main` via `_hub_main_rev()` (ptr727/ProjectTemplate#1017) + for the same reason `_git_revisions` does: ROOT's checked-out branch is not necessarily + `main`, and a develop-only revision matching `blob_sha` must not read as "stale" against a + hub history `main` doesn't actually contain. + """ + walk_rev = _hub_main_rev() if rev is None else rev result = subprocess.run( - ["git", "log", "--format=%H", f"--find-object={blob_sha}", "--", rel_path], + ["git", "log", "--format=%H", f"--find-object={blob_sha}", walk_rev, "--", rel_path], cwd=ROOT, capture_output=True, text=True, @@ -1891,10 +2003,7 @@ def check_intent_staleness(slug, ground, path, canonical_rel, down_text): if hub_change is None: return [] if down_text is not None: - try: - canon_text = (ROOT / canonical_rel).read_text(encoding="utf-8", errors="replace") - except OSError: - canon_text = None + canon_text = canonical_current_text(canonical_rel) if canon_text is not None and content_hash(down_text) == content_hash(canon_text): return [] commits = gh(f"repos/{slug}/commits?path={path}&sha={ground}&per_page=1") @@ -1921,10 +2030,9 @@ def check_verbatim(label, down_text, canonical_rel, extract=None): and classify a mismatch as stale or modified via the canonical's git history. All findings are DRIFT: a byte diff is a hint to review, never proof of breakage. """ - try: - # Same decode policy as the downstream copy and the git history, so a stray byte can never make otherwise-equal content hash differently across the three sources. - canon_text = (ROOT / canonical_rel).read_text(encoding="utf-8", errors="replace") - except OSError: + # canonical_current_text() and git_file_history() both read the same _git_revisions() call, so a copy matching today's main can never mismatch against its own history's newest entry (ptr727/ProjectTemplate#1017 review). + canon_text = canonical_current_text(canonical_rel) + if canon_text is None: return [ ( "DRIFT", @@ -3306,7 +3414,7 @@ def _selftest(): ROOT = tmp_root_path try: _git_revisions.cache_clear() - revisions = _git_revisions(rel) + revisions = _git_revisions(rel, rev="HEAD") finally: ROOT = saved_root _git_revisions.cache_clear() @@ -3351,7 +3459,7 @@ def _selftest(): ROOT = tmp_root_path try: _git_revisions.cache_clear() - revisions = _git_revisions(rel) + revisions = _git_revisions(rel, rev="HEAD") finally: ROOT = saved_root _git_revisions.cache_clear() @@ -3403,7 +3511,7 @@ def _selftest(): ROOT = tmp_root_path try: _git_revisions.cache_clear() - revisions = _git_revisions(rel) + revisions = _git_revisions(rel, rev="HEAD") finally: ROOT = saved_root _git_revisions.cache_clear() @@ -3415,6 +3523,122 @@ def _selftest(): f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: file-to-symlink transition" ) + # _git_revisions: the default rev reads origin's `main`, not ROOT's checked-out branch (ptr727/ProjectTemplate#1017). + # `origin` is a plain local path here, so the fetch stays offline. + with tempfile.TemporaryDirectory() as tmp_upstream, tempfile.TemporaryDirectory() as tmp_root: + tmp_upstream_path = pathlib.Path(tmp_upstream) + tmp_root_path = pathlib.Path(tmp_root) + rel = "hub-probe.txt" + for cmd in ( + ["git", "init", "-q", "-b", "main"], + ["git", "config", "user.email", "test@test.invalid"], + ["git", "config", "user.name", "test"], + ): + subprocess.run(cmd, cwd=tmp_upstream_path, check=True, capture_output=True) + (tmp_upstream_path / rel).write_text("main-content\n") + subprocess.run(["git", "add", rel], cwd=tmp_upstream_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "add on main"], + cwd=tmp_upstream_path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "clone", "-q", str(tmp_upstream_path), str(tmp_root_path)], + check=True, + capture_output=True, + ) + # A clone carries no committer identity of its own (no global config on a CI runner either), so this repo needs the same setup as tmp_upstream_path above. + for cmd in ( + ["git", "config", "user.email", "test@test.invalid"], + ["git", "config", "user.name", "test"], + ): + subprocess.run(cmd, cwd=tmp_root_path, check=True, capture_output=True) + subprocess.run( + ["git", "checkout", "-q", "-b", "develop"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + (tmp_root_path / rel).write_text("develop-only-content\n") + subprocess.run(["git", "add", rel], cwd=tmp_root_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "develop-only change"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + saved_root = ROOT + ROOT = tmp_root_path + try: + _git_revisions.cache_clear() + _hub_main_rev.cache_clear() + revisions = _git_revisions(rel) + finally: + ROOT = saved_root + _git_revisions.cache_clear() + _hub_main_rev.cache_clear() + got = [text for _, _, text in revisions] + want = ["main-content\n"] + if got != want: + ok = False + print( + f" {'ok ' if got == want else 'FAIL'} want={want!s:<24} got={got!s:<24} _git_revisions: default rev reads origin's main, not the checked-out branch" + ) + + # hub_tracked(): a tracked filename with a byte the active locale rejects must round-trip rather than crash. + # git ls-tree -z is NUL-delimited raw bytes, and decoding it as text before the NUL-split (the bug review caught) raises UnicodeDecodeError instead of enumerating the path. + encoding = locale.getpreferredencoding(False) + invalid_bytes = None + for candidate in (b"\xff", b"\x80\x81", b"\xfe\xff"): + try: + candidate.decode(encoding) + except UnicodeDecodeError: + invalid_bytes = candidate + break + if invalid_bytes is None: + # No candidate is actually invalid under this host's active encoding: skip rather than asserting a regression the fixture cannot exercise here. + print(f" skip hub_tracked: no candidate byte sequence is invalid under {encoding!r}") + else: + bad_name = "bad-" + invalid_bytes.decode("utf-8", errors="surrogateescape") + "-name.txt" + with tempfile.TemporaryDirectory() as tmp_root: + tmp_root_path = pathlib.Path(tmp_root) + for cmd in ( + ["git", "init", "-q", "-b", "main"], + ["git", "config", "user.email", "test@test.invalid"], + ["git", "config", "user.name", "test"], + ): + subprocess.run(cmd, cwd=tmp_root_path, check=True, capture_output=True) + try: + (tmp_root_path / bad_name).write_text("x") + except OSError: + # This host's filesystem cannot represent the byte: skip rather than aborting the whole --selftest run over an environment limitation, not a code fault. + print(" skip hub_tracked: non-UTF-8 filename (host cannot create it)") + else: + subprocess.run( + ["git", "add", "-A"], cwd=tmp_root_path, check=True, capture_output=True + ) + subprocess.run( + ["git", "commit", "-q", "-m", "add invalid-utf8 name"], + cwd=tmp_root_path, + check=True, + capture_output=True, + ) + saved_root = ROOT + ROOT = tmp_root_path + try: + hub_tracked.cache_clear() + tracked = hub_tracked(rev="HEAD") + finally: + ROOT = saved_root + hub_tracked.cache_clear() + got = bad_name in tracked + if not got: + ok = False + print( + f" {'ok ' if got else 'FAIL'} want=True got={got!s:<24} hub_tracked: a non-UTF-8 filename round-trips instead of crashing" + ) + # Region extraction and hashing: a forked github-release block must hash differently from the canonical. region = split_jobs(rel_ok).get("github-release") forked_region = split_jobs( @@ -4859,7 +5083,7 @@ def _selftest(): # The hub-only set is the manifest subtracted from the hub's tracked files, so a declared path must never appear in it. # Asserted against the live manifest rather than a fixture, since the failure this guards is a declared path leaking into the deletion list, which only the real pairing can show. declared = {e["path"] for e in load("spec/files.json")["baseline"]} - hub_only = hub_only_paths({"files": load("spec/files.json")}) + hub_only = hub_only_paths({"files": load("spec/files.json")}, rev="HEAD") leaked = sorted(declared & hub_only) if leaked or not hub_only: ok = False diff --git a/spec/divergences.json b/spec/divergences.json index d7b42a6b..5a16e7fa 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -20,7 +20,7 @@ { "path": "scripts/README.md", "disposition": "accepted", "reason": "A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's.", "tracking": null }, { "path": ".github/actionlint.yaml", "disposition": "accepted", "reason": "A path collision rather than a carry. HomeAutomation-Config's own copy declares self-hosted-runner labels (homelab, ubuntu-24.04) for its self-hosted CI runner, entirely different content from the hub's own file at this path, which configures $/ self-reference ignore rules for the hub's own workflows. Verified by reading both copies on 2026-08-25.", "tracking": null }, { "path": ".github/actions/validate/action.yml", "disposition": "accepted", "reason": "A path collision rather than a carry. HomeAutomation-Config's own copy overrides the interface-workflow validate hook, per RESYNC.md 'Apply, in This Order' item 4, 'Interface workflows': 'Honor the named contract... rather than copying bytes. The body is the repository's own.' It runs its CloudInit/ nested Python project through uv/ruff/pyright/pytest. The hub's own file at this same path is a different override, its own registry/spec self-test suite. A repo declaring its own .github/actions/validate/action.yml is the documented, intended override mechanism, not drift to reconcile. Verified by reading both copies on 2026-08-25.", "tracking": null }, - { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent.", "tracking": null }, + { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the Python repos carry an equivalent. reports/divergences.md already shows the carriers. The decision is tracked in ptr727/ProjectTemplate#669, so the remaining call is the fleet-wide new-findings tradeoff, which stays the maintainer's.", "tracking": "ptr727/ProjectTemplate#669" }, { "path": ".github/workflows/get-version-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. Every copy is the hub's own NBGV logic with nothing per-repo in it beyond the action pins Dependabot already owns. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore, KiCadLibrary, aiopurpleair, and homeassistant-purpleair. Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, { "path": ".github/workflows/publish-plan-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, and Utilities, and all three carry a strict subset of the canonical, missing the -E in set -Eeuo pipefail and the ::warning:: branch for an unrecognized actor pushing to main (WORKFLOW.md D8.4). Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, { "path": ".github/workflows/validate-task.yml", "disposition": "retire", "reason": "The file is hub-hosted as a workflow_call task rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\" and docs/reusable-workflows.md \"Stage 2: The Gates\". The fleet doc-lint block, the language lint, the prose gate, and the repo gate move into the hub task, and a repo's own domain checks move into its own .github/actions/validate/action.yml hook instead, so a downstream copy is retired rather than re-vendored. The thirteen repos carrying a copy as of 2026-08-16 were PhotoCleaner, PlexCleaner, LanguageTags, Utilities, MediaTools, AudioCleaner, aiopurpleair, Financial-Modeling, Blog, ESPHome-NonRoot, NxWitness, VSCode-Server-DotNetCore, and HomeAutomation-Config, and the live current carrier list above may have moved on since. Delete the copy and adopt the caller stub in docs/reusable-workflows.md \"Adopting the Gates\" as each repo is next visited.", "tracking": null },