diff --git a/.editorconfig b/.editorconfig index e95c05b..22e58dd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -44,6 +44,10 @@ end_of_line = crlf [*.sh] end_of_line = lf +# Dockerfiles - CRLF breaks RUN heredocs and line continuations +[{Dockerfile,*.Dockerfile}] +end_of_line = lf + # Windows scripts [*.{cmd,bat,ps1}] end_of_line = crlf diff --git a/.gitattributes b/.gitattributes index d4823c7..4b09c0b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,22 @@ -# Leave line endings alone -# git config --global core.autocrlf false -# git add --renormalize . -# git ls-files --eol -* -text +# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. +# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. +# git config --global core.autocrlf false +# git add --renormalize . +# git ls-files --eol +* -text + +# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` +# covers `*.sh`, but extensionless executables (s6 service scripts, hooks) match no extension rule, so pin them here so +# git enforces LF on checkout and `--renormalize`. A repo shipping extensionless scripts adds an explicit path rule, +# e.g. for s6-overlay init: `Docker/s6-overlay/** text eol=lf`. +*.sh text eol=lf + +# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. +Dockerfile text eol=lf +*.Dockerfile text eol=lf + +# Extensionless executables must stay LF - a CRLF shebang breaks execution. The Husky.Net git hook matches no extension rule. +.husky/pre-commit text eol=lf + +# LanguageData/ holds downloaded source data the parser reads byte-for-byte; never normalize it. The `* -text` default +# above preserves it exactly as downloaded - do NOT add a `text`/`eol=` rule here. diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index dc82125..0624ebf 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -1,15 +1,10 @@ name: Build BYOB date badge task +# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks +# the last main build. + on: workflow_call: - inputs: - # Logical branch this badge run is for. The badge only updates on - # `main`; the publisher passes the branch explicitly so a scheduled - # run building `develop` doesn't try to write the main badge. Required - # (no `github.ref_name` fallback) so the gate can't silently misfire. - branch: - required: true - type: string jobs: @@ -21,13 +16,10 @@ jobs: - name: Get current date step id: date - run: | - set -euo pipefail - echo "date=$(date)" >> $GITHUB_OUTPUT + run: echo "date=$(date)" >> "$GITHUB_OUTPUT" - name: Build BYOB date badge step - if: ${{ inputs.branch == 'main' }} - uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 + uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 with: name: lastbuild label: "Last Build" diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 264b973..7949fd6 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -3,12 +3,12 @@ name: Build project release task on: workflow_call: inputs: - # Input to control whether to create a GitHub release + # Whether to create a GitHub release. github: required: false type: boolean default: false - # Input to control whether to push the library to NuGet.org + # Whether to push the library to NuGet.org. nuget: required: false type: boolean @@ -35,6 +35,12 @@ on: required: false type: boolean default: true + # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the + # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. + expect_release_assets: + required: false + type: boolean + default: true jobs: @@ -76,7 +82,28 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} + # Backstop (main only): a public release must not carry a prerelease '-', guarding against NBGV mis-versioning the + # public ref (e.g. a dispatch on a non-default ref) into a malformed "Latest" release. Strip '+buildmetadata' + # first - a '-' there is legitimate; only a '-' in the core/prerelease segment marks a prerelease. + - name: Verify public release version step + if: ${{ inputs.branch == 'main' }} + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + + # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by + # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release + # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops + # every file-producing target (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) + # relaxes that guard. - name: Download release asset artifacts step + if: ${{ inputs.expect_release_assets }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-asset-${{ inputs.branch }}-* @@ -106,6 +133,10 @@ jobs: # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. + # Every release (any branch, any target) is a tag on the built commit plus the auto-attached source zip, README, + # and LICENSE; targets amend it by uploading `release-asset-*` files (binaries/packages) or pushing elsewhere + # (image/registry). `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or + # misnamed; a no-file-target repo relaxes it (see download step). - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 @@ -114,6 +145,7 @@ jobs: tag_name: ${{ needs.get-version.outputs.SemVer2 }} target_commitish: ${{ needs.get-version.outputs.GitCommitId }} prerelease: ${{ inputs.branch != 'main' }} + fail_on_unmatched_files: ${{ inputs.expect_release_assets }} files: | LICENSE README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 5ed1f96..900e063 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -10,7 +10,6 @@ on: type: string default: '' outputs: - # Version information outputs SemVer2: value: ${{ jobs.get-version.outputs.SemVer2 }} AssemblyVersion: diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 74899ef..40be946 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -13,10 +13,11 @@ on: pull_request_target: types: [opened, reopened, synchronize] -# `cancel-in-progress: false` is required so events process to completion in arrival order: a follow-up -# synchronize must not cancel an in-flight `opened` run before it enables auto-merge. +# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR +# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a +# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false jobs: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index df12ef4..8e8cbf0 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -86,20 +86,16 @@ jobs: github: true nuget: true + # Caller-gated to main: the badge has no per-branch context, so it updates only when main is among the published + # branches (a develop-only push skips it). One invocation, not a per-branch matrix leg. date-badge: name: Create BYOB date badge job needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} + if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} uses: ./.github/workflows/build-datebadge-task.yml secrets: inherit permissions: contents: write - with: - # The badge task self-gates to `main`; the develop leg is a no-op. - branch: ${{ matrix.branch }} # Delete the run's artifacts (durable copies live on the GitHub release) to keep them off the account storage quota. cleanup-artifacts: diff --git a/AGENTS.md b/AGENTS.md index 60e2e0e..41dca51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,10 @@ This file is the canonical reference for cross-cutting AI-agent and workflow rul ## Git and Commit Rules -**These rules are absolute - no exceptions:** - -- **Never make git commits.** AI coding agents cannot produce cryptographically signed commits. All commits must be signed (SSH/GPG) and must be made by the developer. Stage changes with `git add` and leave the commit to the developer. +- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. +- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. - **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. -- **Staging is the limit.** Prepare and stage file changes; the developer runs `git commit` in their own environment where signing keys are available. ## Branching Model @@ -44,7 +42,7 @@ This repo uses a **two-phase model by default**: PRs build fast, publishing is b ### Format -- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add ISO 639-3 retired-code handling", not "Added X" or "Adds X".) +- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) - Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. ### Rules @@ -52,7 +50,7 @@ This repo uses a **two-phase model by default**: PRs build fast, publishing is b - Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) - Don't add `Co-Authored-By:` lines unless the developer explicitly asks. - Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *RFC-Compliant*, *24-Hour*). +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). ### Examples @@ -107,7 +105,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging is not releasing.** A merge to `main` does **not** publish - by default `PUBLISH_ON_MERGE` is off, so the push only smoke-runs the publisher's no-op job. Publishing happens solely on the weekly schedule or a manual `workflow_dispatch` (see [Release Model](#release-model)). Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. +**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. ### Expected Review Loop @@ -153,12 +151,20 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul This repo is derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) and re-syncs against it periodically, not just at creation. -- **Verbatim carries.** Pull the current template version of each shared artifact and re-apply it, adapting only this repo's placeholders: [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (the Copilot review runbook - change only the ``/``/`` values in its API snippets), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes), and this file's [PR Review Etiquette](#pr-review-etiquette) section. The `.editorconfig` EOL/per-extension block is always-verbatim; its `[*.cs]`/ReSharper block is .NET-only and is carried here. Keep `copilot-instructions.md` **narrow** (provider mechanics plus the commit/PR-title summary); project-specific conventions and API contracts live in this file (see [Library API Conventions](#library-api-conventions)), not there - non-Copilot agents are not directed to that file. -- **CODESTYLE.md.** Re-sync the whole file from the template, then keep the **General** section plus the **.NET** language section and drop the language sections this repo doesn't ship (the per-language sections are droppable, exactly like the `.editorconfig` `[*.cs]` block; this repo is .NET-only, so the Python section is dropped). Repo-root placement is load-bearing - `AGENTS.md` and `.github/copilot-instructions.md` link it by relative path. Adapt the in-section repo-specific bits: the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels. Replacing the file wholesale and dropping whole sections is simpler to keep current than hand-editing per-language snippets. +- **Verbatim carries.** Pull the current template version of each shared artifact and re-apply it, adapting only this repo's placeholders: [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (the Copilot review runbook - change only the ``/``/`` values in its API snippets), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes), and this file's [PR Review Etiquette](#pr-review-etiquette) section. Carry [`.editorconfig`](./.editorconfig) **whole** - the EOL/per-extension block and the `[*.cs]`/ReSharper block both, even sections for languages this repo doesn't ship (an inert block costs nothing and keeps re-sync a clean overwrite). Keep `copilot-instructions.md` **narrow** (provider mechanics plus the commit/PR-title summary); project-specific conventions and API contracts live in this file (see [Library API Conventions](#library-api-conventions)), not there - non-Copilot agents are not directed to that file. +- **CODESTYLE.md.** Carry the **whole file verbatim** from the template, every language section included - the Python section is inert in this .NET-only repo but costs nothing and keeps re-sync a clean wholesale overwrite rather than a per-section merge. Repo-root placement is load-bearing - `AGENTS.md` and `.github/copilot-instructions.md` link it by relative path. Adapt the in-section repo-specific bits: the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels. - **.vscode/tasks.json.** Carry the named **clean-compile** task definitions verbatim - `.NET Build`, `CSharpier Format`, and `.NET Format` (which chains the first two then `dotnet format style --verify-no-changes`). Their names are owned by the `CODESTYLE.md` ".NET" section and their command sequence + arguments are the canonical clean-compile spec; don't loosen them. Convenience tasks (`.NET Tool Update`, `.NET Outdated Upgrade`, `Husky.Net Run`) are the adapt zone. - **Release notes.** Keep a short release-notes summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. - **Report drift upstream.** When a re-sync surfaces a template gap, an outdated instruction, or something that bit this repo and would bite the next derived repo, open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only patching locally - the template is the single source of truth, and this upstream-issue rule is this repo's only cross-repo obligation. Do not maintain or reference a "known downstream" registry, and do not name sibling repositories in docs, comments, or workflows - that registry and the maintainer fan-out duty live in the template hub only. +### Template adaptations + +Intentional, documented deviations from the carried template state. Everything not listed here tracks the template verbatim. + +- **Husky.Net pre-commit gate.** This repo wires the clean-compile checks as local Husky.Net pre-commit git hooks (installed via `dotnet tool restore` + `dotnet husky install`); the `Husky.Net Run` VS Code task runs them manually. The template ships no git hooks by default and treats CI as the only lint backstop, so [`CODESTYLE.md`](./CODESTYLE.md)'s git-hook note and the `.vscode/tasks.json` convenience-task set are adapted accordingly. CI still runs the same checks as a backstop. +- **Codegen uses the `LanguageTagsCreate` CLI, no `NINJA_API_KEY`.** Embedded language data is regenerated by the in-repo [`LanguageTagsCreate/`](./LanguageTagsCreate/) tool pulling directly from the official ISO 639-2/3 + RFC 5646 registries. There is no external codegen API, so this repo carries no `NINJA_API_KEY` secret or any reference to one. +- **`merge-upstream-version` job legitimately absent.** [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) carries only `merge-dependabot`, `merge-codegen`, and `disable-auto-merge-on-maintainer-push`. The template's `merge-upstream-version` job auto-merges an upstream-version-bump PR flow this repo does not run (LanguageTags pins no upstream binary version), so that job is intentionally not carried. The concurrency keying and the three carried jobs match the template verbatim. + ## Workflow YAML Conventions These conventions describe the target state. New and modified workflows must respect them; the rest of the repo is expected to be brought up to the same standard. diff --git a/CODESTYLE.md b/CODESTYLE.md index 1dd97b8..7dcad42 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,6 +1,6 @@ # Code Style and Formatting Rules -This is the single code-style guide for the repo. The **General** section applies to every language and is always carried. Each **language section** is self-contained and **droppable**: a repo with no .NET side drops the .NET section - the same per-language model as [`.editorconfig`](./.editorconfig), whose `[*.cs]` block a non-.NET repo drops. +This is the single code-style guide for the repo. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo reads only the section(s) for the languages it ships and ignores the rest. The whole file is carried, not trimmed - an unused-language section costs nothing and keeps re-sync a clean overwrite, the same carry-whole model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. @@ -10,7 +10,7 @@ These rules apply to every language in the repo. ### Tooling Names and Casing -Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `Husky.Net`. Don't invent personal variants. +Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `ruff`, `pyright`, `uv`. Don't invent personal variants. ### Clean-Compile Verification @@ -18,13 +18,14 @@ Each language defines a **clean-compile** verification - the combination of buil - **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). +- **A local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated - but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless; a local gate is an additive convenience a repo may wire and keep - Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions - **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md) and [README.md](./README.md), which has nothing to do with code analysis.) - **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. - 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig`). + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. - **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. @@ -32,14 +33,14 @@ Each language defines a **clean-compile** verification - the combination of buil These apply repo-wide, in every directory: -1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. This file is carried verbatim by every derived repo (see [AGENTS.md "Staying in Sync with the Template"](./AGENTS.md#staying-in-sync-with-the-template)). Fix violations at the source rather than disabling rules. +1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. 2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in the workspace CSpell config. ## .NET -*This section applies only to the .NET side. A repo with no .NET projects drops the whole section.* +*This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -This is the style guide for the **.NET projects** in this repo: [`LanguageTags/`](./LanguageTags/) (the published `ptr727.LanguageTags` library), [`LanguageTagsCreate/`](./LanguageTagsCreate/) (the codegen CLI), and [`LanguageTagsTests/`](./LanguageTagsTests/) (the xUnit suite). +This is the style guide for any **.NET projects** in this repo. ### Build Requirements @@ -52,23 +53,20 @@ This is the style guide for the **.NET projects** in this repo: [`LanguageTags/` - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - - `latest-all` - - `true` - - Analyzer severity is `suggestion`, but all warnings must be addressed - see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions); do not relax rules to dodge them. + - `true` with `latest-all` and `All` (full analyzer set enabled) + - `true` - any diagnostic surfaced as a warning fails the build, so it must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)) 3. **CI lint backstop** - - [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) runs `dotnet husky run` on every PR - the same CSharpier + `dotnet format style --verify-no-changes` checks the pre-commit hooks run locally + - CI runs the clean-compile checks on every PR as the authoritative backstop + - Git hooks are optional; a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters #### Build Tasks -Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The first three are the clean-compile set, carried verbatim; the rest are convenience tasks a derived repo adapts or drops: +Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The three clean-compile tasks below are carried verbatim; a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: Build with diagnostic verbosity *(clean-compile)* - `CSharpier Format`: Auto-format code with CSharpier *(clean-compile)* - `.NET Format`: Run CSharpier and build, then verify formatting and style with `--verify-no-changes` *(clean-compile; the task to run after edits)* -- `.NET Tool Update`: Update dotnet tools *(convenience)* -- `.NET Outdated Upgrade`: Upgrade outdated NuGet dependencies, interactive prompt *(convenience)* -- `Husky.Net Run`: Run the configured Husky.Net pre-commit hooks manually *(convenience)* ### Tooling and Editor @@ -82,7 +80,7 @@ Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run - `dotnet-outdated-tool`: Dependency update checks - Nerdbank.GitVersioning: Version management -Husky.Net runs the clean-compile checks as pre-commit git hooks. It is installed as a local dotnet tool (`dotnet tool restore`); run `dotnet husky install` to register the hooks. CI runs the same checks as a backstop. +CI is the authoritative lint backstop. Local pre-commit hooks are optional - wire Husky.Net (or another runner) if you want local enforcement. #### Editor Baseline @@ -110,7 +108,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - Top-level statements for console apps - Pattern matching over traditional checks - Collection expressions when types loosely match - - Extension methods using `extension()` syntax + - Extension methods - the classic `this`-parameter form, or an `extension() { ... }` block on C# 14+ - Implicit object creation when type is apparent - Range and index operators @@ -202,7 +200,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - `true` - Missing XML comments for public APIs are suppressed (`.editorconfig`) - Must document all public surfaces. - - Single-line summaries, additional details in remarks, document input parameters, returns values, exceptions, and add crefs + - Single-line summaries, additional details in remarks, document input parameters, return values, exceptions, and add crefs ```csharp /// @@ -330,14 +328,136 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- - Include SourceLink: `true` - Embed untracked sources: `true` -4. **Internal visibility**: Use `InternalsVisibleTo` for test access (adapt the project names to your repo's test projects) +4. **Internal visibility**: Use `InternalsVisibleTo` for test and benchmark access (adapt the project names to your repo's test/benchmark projects) ```xml - + + ``` ### Best Practices 1. **Code reviews**: All changes go through pull requests + +## Python + +*This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* + +This is the style guide for any **Python project(s)** in this repo. + +### Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv](https://docs.astral.sh/uv/) | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | +| [hatchling](https://hatch.pypa.io/latest/) | build backend | `pyproject.toml` `[build-system]` | +| [ruff](https://docs.astral.sh/ruff/) | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright](https://microsoft.github.io/pyright/) | type checker | `pyproject.toml` `[tool.pyright]` | +| [pytest](https://docs.pytest.org/) | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | + +`pyright` is consumed in two places: as a dev dependency (`uv run pyright` for CI/scripted runs) and via VS Code's **Pylance** extension (which embeds pyright). The standalone `ms-pyright.pyright` extension is in `unwantedRecommendations` because Pylance covers it. `mypy` is **not used** here - don't introduce it. + +### Local Development Loop + +From inside the Python project directory: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist +``` + +The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. + +### Layout + +`src` layout - keeps the package out of the repo root and prevents accidental imports of unbuilt code: + +```text +/ + pyproject.toml + README.md + uv.lock # committed for reproducible CI + src/ + / + __init__.py + _version.py + .py + tests/ + __init__.py + test_.py +``` + +### Code Style + +#### Formatting and Linting + +- **`ruff format` is authoritative.** Don't argue with the formatter; if it reformats your code, that's the final form. Configure (line length, target version) in `pyproject.toml` `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes; let the tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine when you need to document a non-obvious implementation constraint, a local trade-off, or coupling that future edits could easily break. Keep that rationale next to the affected block so the reviewer/maintainer sees it at edit-time. +- **Don't explain *what* the code does** - well-named identifiers handle that. Don't reference the current task ("added for X", "used by Y"); that belongs in the PR description. + +#### Docstrings + +- Follow [PEP 257](https://peps.python.org/pep-0257/). Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior - non-obvious test scenarios, contracts a test pins, edge cases callers must know about, design trade-offs that are load-bearing for future maintainers - write a one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when the contract earns it. +- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`](./HISTORY.md) - that file is end-user release notes, not a design log. + +#### Type Hints + +- **All public APIs are typed.** Pyright runs on `src/` in strict mode (`[tool.pyright]` `strict = ["src"]`); tests run in standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private; double leading underscore for name-mangled (rare - usually means rethink the design). + +#### Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block separated by a blank line - ruff enforces this automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +#### Patterns to Avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars** - just delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code; only validate at boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for *unexpected* states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific exception and document why it's safe, or let it propagate. + +### Tests + +- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. +- One test file per module under test, named `test_.py`. +- Test functions named `test__` - descriptive, not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks when you refactor *without changing behavior*, the test is asserting on an implementation detail. + +### Versioning + +`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a version.json bridge, or manual bumps), no new PyPI versions will land - publishing with `skip-existing: true` keeps a stuck placeholder version from failing the run. + +### Linter Cleanliness + +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 && uv run pyright && uv run pytest` - same as the local commands above, run from the Python project directory. +- Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules.