From 002f82e9a98048b753c10a437bead54fdbe14dc4 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:23:41 +0000 Subject: [PATCH 01/20] More context --- .config/ruff.toml | 1 + .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 6 +++--- .pre-commit-config.yaml | 14 +++++++------- extensions/context.py | 16 ++++++++-------- pyproject.toml | 2 +- uv.lock | 8 ++++---- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.config/ruff.toml b/.config/ruff.toml index 61d486d4..35a1ebc6 100644 --- a/.config/ruff.toml +++ b/.config/ruff.toml @@ -43,6 +43,7 @@ ignore = [ # rules that conflict with the Ruff formatter https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191","E111","E114","E117","D206","Q000","Q001","Q002","Q003","COM812","COM819","ISC001","ISC002","E501", + "CPY001", # We don't care about having copyright headers in every file # spellchecker:disable-line "D100", # Docstrings are not always necessary for public modules "D101", # Docstrings are not always necessary for public classes "D102", # Docstrings are not always necessary for public methods diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 11ac2360..e5d6ac8b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): fb8b636d # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 7fec63b5 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 96d9bf28..bb6eec37 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -5,9 +5,9 @@ import subprocess import sys -UV_VERSION = "0.12.1" -PNPM_VERSION = "11.20.0" -COPIER_VERSION = "9.17.0" +UV_VERSION = "0.12.3" +PNPM_VERSION = "11.21.0" +COPIER_VERSION = "9.17.1" COPIER_TEMPLATE_EXTENSIONS_VERSION = "0.3.3" PRE_COMMIT_VERSION = "4.5.1" GITHUB_WINDOWS_RUNNER_BIN_PATH = r"C:\Users\runneradmin\.local\bin" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82320aba..39545016 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: # Reformatting (should generally come before any file format or other checks, because reformatting can change things) - repo: https://github.com/crate-ci/typos - rev: 96d9af6217dc2855210655af7e1ff19d23d4e593 # frozen: v1 + rev: 12ffd4a04b4893ab75db66a59aaad2d996c3e982 # frozen: v1 hooks: - id: typos args: [--write-changes, --force-exclude, --config, .config/_typos.toml] @@ -104,7 +104,7 @@ repos: )$ - repo: https://github.com/rbubley/mirrors-prettier - rev: 9337a74165b178ae2c766f60bee7252a0f06f3e8 # frozen: v3.9.5 + rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 hooks: - id: prettier args: [--config, .config/.prettierrc] @@ -174,7 +174,7 @@ repos: - id: check-case-conflict - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 1a4bb160cab6417b3045e1b37b6b72449243e658 # frozen: 0.37.4 + rev: c4654a7e007c9619a5eb63c39a456572a0fa1851 # frozen: 0.38.0 hooks: - id: check-github-workflows @@ -219,18 +219,18 @@ repos: exclude: docs/.*\.rst$ - repo: https://github.com/hadolint/hadolint - rev: 4e697ba704fd23b2409b947a319c19c3ee54d24f # frozen: v2.14.0 + rev: 2eece55955ced00200be9729e9728cb7dacca505 # frozen: v2.15.1 hooks: - id: hadolint-docker name: Lint Dockerfiles # The hadolint pre-commit has a problem where it just always pulls the `latest` image at runtime. # Explicitly pinning the image here to the same rev as the hook to patch it until https://github.com/hadolint/hadolint/pull/1177 gets resolved. - entry: ghcr.io/hadolint/hadolint:v2.14.0@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint + entry: ghcr.io/hadolint/hadolint:v2.15.1@sha256:fdf19d026b54834f88c62774fdf2a61ff3b586a42632e5070674d41a796bfdf3 hadolint exclude: .*\.jinja$ description: Runs hadolint to lint Dockerfiles - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 2700fd5671c633760d912769c041bfcde2b9a01b # frozen: v0.15.22 + rev: 65dbdb59d2f2d9c3bdc343c566821ad3319ddaa3 # frozen: v0.16.3 hooks: - id: ruff name: ruff-src @@ -261,7 +261,7 @@ repos: )$ - repo: https://github.com/pylint-dev/pylint - rev: 8a396357098337ba8be714fffdf2d00947a9778c # frozen: v4.0.6 + rev: f5a6e3b1de17502747a40d7f92f285ccf9c6c459 # frozen: v4.0.7 hooks: - id: pylint name: pylint diff --git a/extensions/context.py b/extensions/context.py index 5d463d6b..ef5ebbef 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -13,11 +13,11 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just self, context: dict[Any, Any] ) -> dict[Any, Any]: # These are duplicated in the install-ci-tooling.py script in this repository - context["uv_version"] = "0.12.1" + context["uv_version"] = "0.12.3" context["pre_commit_version"] = "4.5.1" - context["pnpm_version"] = "11.20.0" + context["pnpm_version"] = "11.21.0" # These also in pyproject.toml and the install-ci-tooling.py script in this repository - context["copier_version"] = "==9.17.0" + context["copier_version"] = "==9.17.1" context["copier_template_extensions_version"] = "==0.3.3" ####### context["npm_version"] = "11.13.0" @@ -44,20 +44,20 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["strawberry_graphql_version"] = ( "==0.298.0" # problems with strawberry pydantic in later versions...lost the context for exactly why...it was figured out a while ago, but has to do with being able to override resolvers and have the schema still keep the desired nullability ) - context["fastapi_version"] = ">=0.139.0" + context["fastapi_version"] = ">=0.141.1" context["fastapi_offline_version"] = ">=1.7.7" context["starlette_version"] = ">=1.3.1" - context["uvicorn_version"] = ">=0.51.0" + context["uvicorn_version"] = ">=0.52.3" context["lab_auto_pulumi_version"] = ">=0.2.3" context["ariadne_codegen_version"] = ">=0.18.0" context["pytest_mock_version"] = ">=3.15.1" context["uuid_utils_version"] = ">=0.16.2" - context["syrupy_version"] = ">=5.3.4" + context["syrupy_version"] = ">=5.5.3" context["structlog_version"] = ">=26.1.0" context["httpx_version"] = ">=0.28.1" - context["httpx2_version"] = ">=2.5.0" + context["httpx2_version"] = ">=2.10.0" context["python_kiota_bundle_version"] = ">=1.11.6" - context["vcrpy_version"] = ">=8.2.1" + context["vcrpy_version"] = ">=8.3.0" context["pytest_recording_version"] = ">=0.13.4" context["pytest_asyncio_version"] = ">=1.4.0" context["pytest_timeout_version"] = ">=2.4.0" diff --git a/pyproject.toml b/pyproject.toml index 4f1db2ca..6cbe92b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "faker>=40.36.0", "pyrefly>=1.2.0", "mutmut>=3.7.0", - "copier==9.17.0", + "copier==9.17.1", "copier-template-extensions==0.3.3", "pyyaml>=6.0.3" ] diff --git a/uv.lock b/uv.lock index da014363..5b45ac8c 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ wheels = [ [[package]] name = "copier" -version = "9.17.0" +version = "9.17.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama" }, @@ -56,9 +56,9 @@ dependencies = [ { name = "pyyaml" }, { name = "questionary" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/6a/73ccc2ce53c17a29d650246a625df6342f74cb9d4f15b78a77d311fc6007/copier-9.17.0.tar.gz", hash = "sha256:d966b043a15c74595f7904a6af89f3291135682f8313c4b71ef368811ed554f2", size = 645743, upload-time = "2026-07-13T14:33:04.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/65/11c8635a27ce06ef525946bb02b1d5271b0f11e5acf9c5ab6d242512598e/copier-9.17.1.tar.gz", hash = "sha256:778e85f1c2de86824a72ddf4a34140744921d6354450c427c4c8a475371b1bda", size = 647338, upload-time = "2026-08-04T09:15:04.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/2c/68dec88ee26f96af2d4d8ee9d8567313a0826f4fd6bc5ae4527be670d354/copier-9.17.0-py3-none-any.whl", hash = "sha256:fe4e7b59faf4c0e1386eccbb79c6797e6df33ca20d5de49a8372571d8669f2ad", size = 65954, upload-time = "2026-07-13T14:33:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl", hash = "sha256:f6a48f296e2e536c635213e1000ea71099f9f58f3b464a60f2f0fd386e343628", size = 66364, upload-time = "2026-08-04T09:15:03.026Z" }, ] [[package]] @@ -79,7 +79,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "copier", specifier = "==9.17.0" }, + { name = "copier", specifier = "==9.17.1" }, { name = "copier-template-extensions", specifier = "==0.3.3" }, { name = "faker", specifier = ">=40.36.0" }, { name = "mutmut", specifier = ">=3.7.0" }, From 7a2e39a62b786c403962eeb531f2597e04a6b5c4 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:24:47 +0000 Subject: [PATCH 02/20] no pretty' --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e5d6ac8b..5420bf2a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 7fec63b5 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 6e3dedef # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39545016..f68f563b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,19 +77,6 @@ repos: .*\.svg| .*/vendor_files/.*| )$ - - id: pretty-format-json - exclude: | - (?x)^( - .devcontainer/devcontainer.json| - .devcontainer/devcontainer-lock.json| - .*devcontainer.json| - .*tsconfig.json| - .*biome.jsonc| - .*/generated/open[-_]api/.*| - .*tests/.*/__snapshots__/.*| - .*/vendor_files/.*| - )$ - args: [--autofix, --no-sort-keys] - repo: https://github.com/biomejs/pre-commit rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 From 4bc2d372566708df1c3ca36e43c34bb06a2365b7 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:25:24 +0000 Subject: [PATCH 03/20] astroid --- .config/_typos.toml | 2 -- .config/pylintrc.toml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.config/_typos.toml b/.config/_typos.toml index cf8a11d2..69ac6887 100644 --- a/.config/_typos.toml +++ b/.config/_typos.toml @@ -6,8 +6,6 @@ extend-ignore-re = [ [default.extend-words] # Words managed by the base template -# `astroid` is the name of a python library, and it is used in pylint configuration. should not be corrected to asteroid -astroid = "astroid" # Words specific to this repository diff --git a/.config/pylintrc.toml b/.config/pylintrc.toml index 50b94f29..2907a024 100644 --- a/.config/pylintrc.toml +++ b/.config/pylintrc.toml @@ -2,7 +2,7 @@ disable = "all" enable = [ # These are always needed to ensure that a crash within pylint itself appropriately causes a failure - "astroid-error", + "astroid-error", # spellchecker:disable-line "fatal", # Rules relating to errors found parsing this config file "config-parse-error", From 1b96082ee3b9428223341621266d8ee445f08587 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:40:49 +0000 Subject: [PATCH 04/20] tdd --- .claude/commands/green.md | 5 +++-- .claude/commands/issue.md | 5 +++-- .claude/commands/red.md | 5 +++-- .claude/commands/refactor.md | 5 +++-- .claude/commands/spike.md | 5 +++-- .claude/commands/tdd-review.md | 2 +- .claude/commands/tdd.md | 5 +++-- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.claude/commands/green.md b/.claude/commands/green.md index bc1b159b..e2b66f95 100644 --- a/.claude/commands/green.md +++ b/.claude/commands/green.md @@ -39,8 +39,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md index fc39502a..892ea596 100644 --- a/.claude/commands/issue.md +++ b/.claude/commands/issue.md @@ -98,8 +98,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content diff --git a/.claude/commands/red.md b/.claude/commands/red.md index 01dc7124..b3a89073 100644 --- a/.claude/commands/red.md +++ b/.claude/commands/red.md @@ -39,8 +39,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content diff --git a/.claude/commands/refactor.md b/.claude/commands/refactor.md index 15119e16..df37873b 100644 --- a/.claude/commands/refactor.md +++ b/.claude/commands/refactor.md @@ -39,8 +39,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content diff --git a/.claude/commands/spike.md b/.claude/commands/spike.md index 72a8fdb5..6ec9efd1 100644 --- a/.claude/commands/spike.md +++ b/.claude/commands/spike.md @@ -39,8 +39,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content diff --git a/.claude/commands/tdd-review.md b/.claude/commands/tdd-review.md index b3612fb8..305929f9 100644 --- a/.claude/commands/tdd-review.md +++ b/.claude/commands/tdd-review.md @@ -59,7 +59,7 @@ For each test file, check against these criteria: |--------------|-------------------| | **The Liar** | `expect(true).toBe(true)`, empty test bodies, tests with no assertions | | **Excessive Setup** | >20 lines of arrange code, >5 mocks, deep nested object construction | -| **The One** | >5 assertions testing unrelated behaviors in a single test | +| **The One** | Assertions covering unrelated behaviors in a single test, or several Acts strung together under one name - count alone is not the signal, since one Arrange and Act can warrant many assertions | | **The Peeping Tom** | Testing private methods, asserting on internal state, tests that break on any refactor | | **The Slow Poke** | Real database/network calls, file I/O, hard-coded timeouts | diff --git a/.claude/commands/tdd.md b/.claude/commands/tdd.md index 7cb2a554..5cff14f6 100644 --- a/.claude/commands/tdd.md +++ b/.claude/commands/tdd.md @@ -28,8 +28,9 @@ The foundation of TDD is the Red-Green-Refactor cycle: 1. **Red Phase**: Write ONE failing test that describes desired behavior - The test must fail for the RIGHT reason (not syntax/import errors) - - Only one test at a time - this is critical for TDD discipline - - Exception: For browser-level tests or expensive setup (e.g., Storybook `*.stories.tsx`), group multiple assertions within a single test block to avoid redundant setup - but only when adding assertions to an existing interaction flow. If new user interactions are required, still create a new test. Split files by category if they exceed ~1000 lines. + - Only one new behavior at a time - this is critical for TDD discipline. "One test" means one new Given/When, not one assertion: write only enough to fail for the right reason, whether that lands in a new test block or as an assertion added to an existing one. + - Before writing a new test block, look for an existing test with the same Arrange and Act. If one exists, add the failing assertion there. A block that repeats another's setup and action is not more disciplined, just more setup to maintain, and it hides that both cover one behavior. Reserve a new block for a different precondition (Given) or a different action (When). + - Split test files by category if they exceed ~1000 lines - **Adding a single test to a test file is ALWAYS allowed** - no prior test output needed - Starting TDD for a new feature is always valid, even if test output shows unrelated work - For DOM-based tests, use `data-testid` attributes to select elements rather than CSS classes, tag names, or text content From ec4986d0ff1afde7e720764a0219fb3350660839 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:50:17 +0000 Subject: [PATCH 05/20] frontend --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ec7aee53..097f6fa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ This project is a Copier template used to generate other copier templates. It is ### Frontend Testing - Key `data-testid` selectors off unique IDs (e.g. UUIDs), not human-readable names which may collide or change. -- In DOM-based tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. +- In DOM-based unit tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. Browser automation (e.g. Playwright) fails an ambiguous locator outright, so a unique `data-testid` looked up from the page is enough there. # Agent Implementations & Configurations From 963238d48fafd7304bb2ea0eb5af1ef8fdc1bdf8 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 19:58:39 +0000 Subject: [PATCH 06/20] lint --- ...{% if template_uses_typescript %}eslint.shared.mjs{% endif %} | 1 + 1 file changed, 1 insertion(+) diff --git a/template/copier_template_resources/{% if template_uses_typescript %}eslint.shared.mjs{% endif %} b/template/copier_template_resources/{% if template_uses_typescript %}eslint.shared.mjs{% endif %} index 04ed0afd..d2eb2312 100644 --- a/template/copier_template_resources/{% if template_uses_typescript %}eslint.shared.mjs{% endif %} +++ b/template/copier_template_resources/{% if template_uses_typescript %}eslint.shared.mjs{% endif %} @@ -50,6 +50,7 @@ export const vitestConfig = { severity === "off" ? [rule, "off"] : [rule, "error"], ), ), + "vitest/max-expects": ["error", { max: 10 }], // 5 is too low. There are legitimate reasons to have several assertions (especially with DOM tests) that share the same Arrange/Act "vitest/unbound-method": "off", // requires typed linting (parserOptions.project), not configured in this project "vitest/require-mock-type-parameters": "off", // stylistic; vi.fn() inferred type is sufficient and explicit generics add noise on trivial mocks "vitest/prefer-describe-function-title": "off", // autofix rewrites string titles to identifier references, which then conflicts with vitest/valid-title for default-imported functions From 7b6b7bdbb3df30a4583fd65cd86521c5d0857ec9 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 20:52:58 +0000 Subject: [PATCH 07/20] chore: satisfy pyrefly preset "all" Enable every pyrefly error kind and resolve the 54 resulting errors. - implicit-bool: replace truthiness tests with explicit comparisons (== "", len(x) > 0, is not None), and split and-joined guards into nested ifs so each condition is independently covered - explicit-any / unknown-variable-type / unknown-argument-type: add Manifest and TemplateEntry TypedDicts for the copier-managed-files JSON, and prove argparse Namespace attribute types with isinstance asserts rather than declaring them - no-any-return-explicit: narrow re.findall's list[Any] element with an isinstance assert - unannotated-return: ignored under the tests sub-config Fix the tests sub-config matcher along the way: "**/tests/**" never matched anything because matches is resolved relative to the config file, so the existing implicitly-defined-attribute override was inert. Extract _read_ancestor_manifest from main() to stay within the C901 and PLR0912 limits after the branch expansions. --- .config/pyrefly.toml | 7 +- .../copier_tasks/copier_provenance.py | 146 ++++++++++++------ ...ensure_pnpm_minimum_release_age_exclude.py | 8 +- .../copier_tasks/remove_precommit_hooks.py | 16 +- .../openapi/openapi_schema_simplifier.py | 2 +- .../copier_tasks/test_copier_provenance.py | 53 +++++-- .../test_remove_precommit_hooks.py | 2 +- 7 files changed, 157 insertions(+), 77 deletions(-) diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 4324fda6..46fd4b10 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -18,9 +18,7 @@ search-path = [".."] disable-project-excludes-heuristics = true # with this being enabled by default, pyrefly thinks the src is excluded because it's an editable local install python-platform = "linux" infer-with-first-use = false # more similar to pyright. must define the empty list type when instantiated -# "strict" enables the maximalist error set plus strict-callable-subtyping and -# unused-ignore. "preset = all" (every error kind) will be available in v1.1. -preset = "strict" +preset = "all" spec-compliant-overloads = true # not sure exactly what this does, but seems like a good idea to be more spec compliant ignore-errors-in-generated-code = true # not sure yet how effective this is @@ -30,7 +28,8 @@ no-access = "ignore" # (already covered by ruff SLF001) untyped-import = "ignore" [[sub-config]] -matches = "**/tests/**" +matches = "../tests/**" [sub-config.errors] implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 +unannotated-return = "ignore" # test functions always return None; pyright never required this and annotating every test adds noise without catching anything diff --git a/src/copier_base_template/copier_tasks/copier_provenance.py b/src/copier_base_template/copier_tasks/copier_provenance.py index 6847d2ec..a37f4d3f 100644 --- a/src/copier_base_template/copier_tasks/copier_provenance.py +++ b/src/copier_base_template/copier_tasks/copier_provenance.py @@ -4,13 +4,24 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any from typing import Literal +from typing import NotRequired +from typing import TypedDict CommentType = Literal["hash", "batch", "block", "jinja", "markdown", "none"] Location = Literal["top", "bottom", "none"] +class TemplateEntry(TypedDict): + src: str + parent_src: NotRequired[str] + managed_files: list[str] + + +class Manifest(TypedDict): + templates: list[TemplateEntry] + + @dataclass class CommentFormat: comment_type: CommentType = "hash" @@ -70,7 +81,7 @@ def _find_manifest(base_directory: Path) -> Path: def _build_header(template_src: str) -> str: """Return the header text. With a template_src, embeds the URL on its own line.""" - if not template_src: + if template_src == "": return _HEADER_BASE lines: list[str] = list(_HEADER_BASE.split("\n")) # Replace the generic "File is managed" line with two lines: URL line + "See ..." line. @@ -88,7 +99,8 @@ def get_base_filename(template_filename: str) -> str: - Plain template file: README.md.jinja-base → README.md (strip template suffix). """ result = re.findall(r"%\}(.*?)\{%", template_filename, re.DOTALL) - if result: + if len(result) > 0: + assert isinstance(result[0], str) return result[0] for suffix in [".jinja-base", ".jinja"]: if template_filename.endswith(suffix): @@ -99,15 +111,15 @@ def get_base_filename(template_filename: str) -> str: def _build_specific_header(comment_type: CommentType, template_src: str = "") -> str | None: header = _build_header(template_src) if comment_type == "hash": - return "\n".join(f"# {line}" if line else "#" for line in header.split("\n")) + return "\n".join(f"# {line}" if line != "" else "#" for line in header.split("\n")) if comment_type == "batch": - return "\n".join(f"REM {line}" if line else "REM" for line in header.split("\n")) + return "\n".join(f"REM {line}" if line != "" else "REM" for line in header.split("\n")) if comment_type == "block": - body = "\n".join(f" * {line}" if line else " *" for line in header.split("\n")) + body = "\n".join(f" * {line}" if line != "" else " *" for line in header.split("\n")) return f"/*\n{body}\n */" if comment_type == "jinja": # Jinja renders {# ... #} to empty string, so this marker is invisible in rendered output. - body = "\n".join(f" {line}" if line else "" for line in header.split("\n")) + body = "\n".join(f" {line}" if line != "" else "" for line in header.split("\n")) return f"{{#\n{body}\n#}}" if comment_type == "markdown": return f"" @@ -157,7 +169,7 @@ def _resolve_file_src( ancestor_managed_by_src: dict[str, set[str]] | None, ) -> str: """Return the template src that originally contributed this file path.""" - if ancestor_managed_by_src: + if ancestor_managed_by_src is not None: for origin_src, origin_files in ancestor_managed_by_src.items(): if rel_str in origin_files: return origin_src @@ -243,7 +255,9 @@ def _read_parent_src(src_template_directory: Path) -> str | None: return None text = answers_path.read_text(encoding="utf-8") m = re.search(r"^_src_path:\s*(.+)$", text, re.MULTILINE) - return m.group(1).strip() if m else None + if m is None: + return None + return m.group(1).strip() def update_manifest( @@ -256,17 +270,21 @@ def update_manifest( manifest_path = dst_directory / _MANIFEST_RELPATH manifest_path.parent.mkdir(parents=True, exist_ok=True) - existing: dict[str, Any] = {} + existing: Manifest = {"templates": []} if manifest_path.exists(): existing = json.loads(manifest_path.read_text(encoding="utf-8")) - templates: list[dict[str, Any]] = existing.get("templates", []) - templates = [t for t in templates if t.get("src") != template_src] + templates: list[TemplateEntry] = [] + for t in existing["templates"]: + if t["src"] == template_src: + continue + templates.append(t) - entry: dict[str, Any] = {"src": template_src} - if parent_src: - entry["parent_src"] = parent_src - entry["managed_files"] = managed_files + # Both branches spell the whole entry out so the JSON key order stays src, parent_src, managed_files. + if parent_src is None: + entry: TemplateEntry = {"src": template_src, "managed_files": managed_files} + else: + entry = {"src": template_src, "parent_src": parent_src, "managed_files": managed_files} templates.append(entry) _ = manifest_path.write_text( @@ -275,60 +293,90 @@ def update_manifest( ) +def _read_ancestor_manifest(src_template_dir: Path) -> tuple[dict[str, set[str]], dict[str, str]]: + """Return each ancestor template's managed paths and its own parent, keyed by template src. + + The ancestor manifest may contain paths with a "template/" prefix (from self-stamp tasks that run + with src=dst=template/). Both the prefixed and stripped spellings are recorded so lookups match the + destination repo's layout (where "template/" doesn't exist). + """ + ancestor_managed_by_src: dict[str, set[str]] = {} + ancestor_parent_by_src: dict[str, str] = {} + ancestor_manifest_path = _find_manifest(src_template_dir.parent) + if not ancestor_manifest_path.exists(): + return ancestor_managed_by_src, ancestor_parent_by_src + + data: Manifest = json.loads(ancestor_manifest_path.read_text(encoding="utf-8")) + subdir_prefix = src_template_dir.name + "/" + for t in data["templates"]: + path_set: set[str] = set() + for f in t["managed_files"]: + path_set.add(f) + stripped = f.removeprefix(subdir_prefix) + path_set.add(stripped) + # Apply get_base_filename to each part so .jinja/.jinja-base suffixes + # and Jinja conditional names resolve to the final destination filename. + parts = Path(stripped).parts + if len(parts) > 0: + resolved = str(Path(*[get_base_filename(p) for p in parts])) + path_set.add(resolved) + ancestor_managed_by_src[t["src"]] = path_set + ancestor_parent = t.get("parent_src") + if ancestor_parent is not None: + ancestor_parent_by_src[t["src"]] = ancestor_parent + return ancestor_managed_by_src, ancestor_parent_by_src + + def main() -> None: parser = argparse.ArgumentParser(description="Add copier provenance markers and manifest") _ = parser.add_argument("src_template_dir", type=Path, help="Template source directory") _ = parser.add_argument("dst_dir", type=Path, help="Destination directory") _ = parser.add_argument("--template-src", default="", help="Template source identifier for the manifest") args = parser.parse_args() + assert isinstance(args.src_template_dir, Path) + assert isinstance(args.dst_dir, Path) + assert isinstance(args.template_src, str) + src_template_dir = args.src_template_dir + dst_dir = args.dst_dir + template_src = args.template_src # header_src drives what URL appears in file headers (empty → generic "managed by a copier template" text). # manifest_src is the key written to .config/.copier-managed-files.json and is always non-empty. - header_src = args.template_src - manifest_src = args.template_src or str(args.src_template_dir) + header_src = template_src + if template_src == "": + manifest_src = str(src_template_dir) + else: + manifest_src = template_src - ancestor_managed_by_src: dict[str, set[str]] = {} - ancestor_parent_by_src: dict[str, str] = {} - ancestor_manifest_path = _find_manifest(args.src_template_dir.parent) - if ancestor_manifest_path.exists(): - data: dict[str, Any] = json.loads(ancestor_manifest_path.read_text(encoding="utf-8")) - # The ancestor manifest may contain paths with a "template/" prefix (from self-stamp - # tasks that run with src=dst=template/). Strip that prefix so paths match the - # destination repo's layout (where "template/" doesn't exist). - subdir_prefix = args.src_template_dir.name + "/" - for t in data.get("templates", []): - path_set: set[str] = set() - for f in t.get("managed_files", []): - path_set.add(f) - stripped = f.removeprefix(subdir_prefix) - path_set.add(stripped) - # Apply get_base_filename to each part so .jinja/.jinja-base suffixes - # and Jinja conditional names resolve to the final destination filename. - parts = Path(stripped).parts - if parts: - resolved = str(Path(*[get_base_filename(p) for p in parts])) - path_set.add(resolved) - ancestor_managed_by_src[t["src"]] = path_set - if t.get("parent_src"): - ancestor_parent_by_src[t["src"]] = t["parent_src"] + ancestor_managed_by_src, ancestor_parent_by_src = _read_ancestor_manifest(src_template_dir) + + ancestor_argument: dict[str, set[str]] | None = None + if len(ancestor_managed_by_src) > 0: + ancestor_argument = ancestor_managed_by_src managed_by_src = apply_file_markers( - src_template_directory=args.src_template_dir, - dst_directory=args.dst_dir, + src_template_directory=src_template_dir, + dst_directory=dst_dir, template_src=header_src, - ancestor_managed_by_src=ancestor_managed_by_src or None, + ancestor_managed_by_src=ancestor_argument, ) # Always write an entry for the current template even when no files matched. _ = managed_by_src.setdefault(header_src, []) - parent_src = _read_parent_src(args.src_template_dir) + parent_src = _read_parent_src(src_template_dir) for src, files in managed_by_src.items(): - effective_src = manifest_src if src == header_src else src + if src == header_src: + effective_src = manifest_src + else: + effective_src = src # Current template's parent comes from copier-answers; ancestor entries carry # their own parent_src forward from the ancestor manifest so the chain survives. - effective_parent = parent_src if effective_src == manifest_src else ancestor_parent_by_src.get(src) + if effective_src == manifest_src: + effective_parent = parent_src + else: + effective_parent = ancestor_parent_by_src.get(src) update_manifest( - dst_directory=args.dst_dir, + dst_directory=dst_dir, template_src=effective_src, managed_files=files, parent_src=effective_parent, diff --git a/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py b/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py index d2fd5a72..4a20a5f8 100644 --- a/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py +++ b/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py @@ -8,7 +8,13 @@ def _parse_patterns(raw: str) -> list[str]: - return [p.strip().strip('"').strip("'") for p in raw.split(",") if p.strip()] + patterns: list[str] = [] + for raw_pattern in raw.split(","): + stripped = raw_pattern.strip() + if stripped == "": + continue + patterns.append(stripped.strip('"').strip("'")) + return patterns def ensure_minimum_release_age_exclude(*, workspace_dir: Path, patterns: list[str]) -> None: diff --git a/src/copier_base_template/copier_tasks/remove_precommit_hooks.py b/src/copier_base_template/copier_tasks/remove_precommit_hooks.py index 9b0f0c25..cb04738d 100644 --- a/src/copier_base_template/copier_tasks/remove_precommit_hooks.py +++ b/src/copier_base_template/copier_tasks/remove_precommit_hooks.py @@ -8,7 +8,9 @@ def _is_matching_hook_block(block_lines: list[str], hook_id_pattern: re.Pattern[str]) -> bool: - return bool(hook_id_pattern.search(block_lines[0])) if block_lines else False + if len(block_lines) == 0: + return False + return hook_id_pattern.search(block_lines[0]) is not None def remove_hook_blocks(config_path: Path, hook_id_pattern: re.Pattern[str]) -> int: @@ -24,7 +26,7 @@ def remove_hook_blocks(config_path: Path, hook_id_pattern: re.Pattern[str]) -> i stripped = line.lstrip() indentation = len(line) - len(stripped) - if HOOK_ID_LINE.match(stripped): + if HOOK_ID_LINE.match(stripped) is not None: block_start = index block_end = index + 1 @@ -32,10 +34,12 @@ def remove_hook_blocks(config_path: Path, hook_id_pattern: re.Pattern[str]) -> i next_line = lines[block_end] next_stripped = next_line.lstrip() next_indentation = len(next_line) - len(next_stripped) - if HOOK_ID_LINE.match(next_stripped) and next_indentation == indentation: - break - if next_stripped and next_indentation < indentation: - break + if HOOK_ID_LINE.match(next_stripped) is not None: + if next_indentation == indentation: + break + if next_stripped != "": + if next_indentation < indentation: + break block_end += 1 block_lines = lines[block_start:block_end] diff --git a/src/copier_base_template/openapi/openapi_schema_simplifier.py b/src/copier_base_template/openapi/openapi_schema_simplifier.py index bcca2f04..3fb9195b 100644 --- a/src/copier_base_template/openapi/openapi_schema_simplifier.py +++ b/src/copier_base_template/openapi/openapi_schema_simplifier.py @@ -34,7 +34,7 @@ def _collapse_anyof(schema: dict[str, JsonValue]) -> None: member_type = member.get("type") if not isinstance(member_type, str): return # a $ref/composed member can't be expressed in the type-array form - if cross_type_constraints & member.keys(): + if len(cross_type_constraints & member.keys()) > 0: return # enum/const apply to every type, so merging them would wrongly reject null and the sibling types typed_dicts.append(member) member_types.append(member_type) diff --git a/tests/unit/copier_tasks/test_copier_provenance.py b/tests/unit/copier_tasks/test_copier_provenance.py index d183fef5..292bd558 100644 --- a/tests/unit/copier_tasks/test_copier_provenance.py +++ b/tests/unit/copier_tasks/test_copier_provenance.py @@ -1,6 +1,8 @@ import json import subprocess from pathlib import Path +from typing import NotRequired +from typing import TypedDict import pytest from faker import Faker @@ -10,6 +12,25 @@ _SCRIPT_PATH = SCRIPT_PATH_ROOT / "copier_provenance.py" + +# The manifest shape is declared here rather than imported from the task module: the scripts live at a +# different import path in child templates (see helpers.SCRIPT_PATH_ROOT), and the test should assert +# against the JSON contract independently of the implementation's own definition. +class _TemplateEntry(TypedDict): + src: str + parent_src: NotRequired[str] + managed_files: list[str] + + +class _Manifest(TypedDict): + templates: list[_TemplateEntry] + + +def _read_manifest(repo_dir: Path) -> _Manifest: + manifest: _Manifest = json.loads((repo_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + return manifest + + expected_hash_comment = """\ # ============== WARNING ============================================================================== # File is managed by a copier template. See .config/.copier-managed-files.json for details. @@ -64,7 +85,7 @@ def _run_script( template_src: str = "", ) -> subprocess.CompletedProcess[str]: args = [str(src_template_dir), str(dst_dir)] - if template_src: + if template_src != "": args += ["--template-src", template_src] result = run_copier_task(_SCRIPT_PATH, *args) assert result.returncode == 0, result.stderr @@ -435,7 +456,7 @@ def test_manifest_created_with_managed_files(self, tmp_path: Path) -> None: template_src="https://github.com/org/base-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) assert len(manifest["templates"]) == 1 entry = manifest["templates"][0] assert entry["src"] == "https://github.com/org/base-template" @@ -461,7 +482,7 @@ def test_manifest_is_idempotent_on_second_run(self, tmp_path: Path) -> None: template_src="https://github.com/org/base-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) assert len(manifest["templates"]) == 1 def test_manifest_layering_preserves_other_template_entries(self, tmp_path: Path) -> None: @@ -484,7 +505,7 @@ def test_manifest_layering_preserves_other_template_entries(self, tmp_path: Path template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) srcs = [t["src"] for t in manifest["templates"]] assert "https://github.com/org/base-template" in srcs assert "https://github.com/org/child-template" in srcs @@ -515,7 +536,7 @@ def test_manifest_child_update_does_not_overwrite_base(self, tmp_path: Path) -> template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) assert len(manifest["templates"]) == expected_num_manifests_in_project base = next(t for t in manifest["templates"] if "base" in t["src"]) assert "a.txt" in base["managed_files"] @@ -533,7 +554,7 @@ def test_manifest_src_matches_template_src_argument(self, tmp_path: Path) -> Non template_src="https://github.com/org/my-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) entry = manifest["templates"][0] assert entry["src"] == "https://github.com/org/my-template" @@ -552,7 +573,7 @@ def test_manifest_structure_is_valid(self, tmp_path: Path) -> None: template_src="https://github.com/org/my-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) assert isinstance(manifest["templates"], list) for entry in manifest["templates"]: assert isinstance(entry["src"], str) @@ -578,8 +599,9 @@ def test_manifest_parent_src_discovered_from_config_copier_answers(self, tmp_pat template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) entry = manifest["templates"][0] + assert "parent_src" in entry assert entry["parent_src"] == "https://github.com/org/parent-template" def test_manifest_parent_src_falls_back_to_root_copier_answers(self, tmp_path: Path) -> None: @@ -599,8 +621,9 @@ def test_manifest_parent_src_falls_back_to_root_copier_answers(self, tmp_path: P template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) entry = manifest["templates"][0] + assert "parent_src" in entry assert entry["parent_src"] == "https://github.com/org/parent-template" def test_manifest_no_parent_src_when_no_copier_answers(self, tmp_path: Path) -> None: @@ -616,7 +639,7 @@ def test_manifest_no_parent_src_when_no_copier_answers(self, tmp_path: Path) -> template_src="https://github.com/org/root-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) entry = manifest["templates"][0] assert entry["src"] == "https://github.com/org/root-template" assert "parent_src" not in entry @@ -653,7 +676,7 @@ def test_ancestor_files_attributed_to_ancestor_template(self, tmp_path: Path) -> template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) srcs = {t["src"]: t for t in manifest["templates"]} assert "https://github.com/org/base-template" in srcs assert "https://github.com/org/child-template" in srcs @@ -696,10 +719,10 @@ def test_ancestor_jinja_suffix_resolved_for_attribution(self, tmp_path: Path) -> template_src="https://github.com/org/child-template", ) - manifest = json.loads((dst_dir / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + manifest = _read_manifest(dst_dir) srcs = {t["src"]: t for t in manifest["templates"]} assert "README.md" in srcs["https://github.com/org/base-template"]["managed_files"] - assert "README.md" not in srcs.get("https://github.com/org/child-template", {}).get("managed_files", []) + assert "README.md" not in srcs["https://github.com/org/child-template"]["managed_files"] def test_full_chain_base_child_final_attribution(self, tmp_path: Path) -> None: # Full 3-level chain: base stamps child (step 1), child stamps grandchild (step 2). @@ -726,7 +749,7 @@ def test_full_chain_base_child_final_attribution(self, tmp_path: Path) -> None: dst_dir=child_repo, template_src="https://github.com/org/base-template", ) - child_manifest = json.loads((child_repo / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + child_manifest = _read_manifest(child_repo) base_entry = next(t for t in child_manifest["templates"] if "base" in t["src"]) assert "template/README.md.jinja" in base_entry["managed_files"] @@ -741,7 +764,7 @@ def test_full_chain_base_child_final_attribution(self, tmp_path: Path) -> None: dst_dir=final_repo, template_src="https://github.com/org/child-template", ) - final_manifest = json.loads((final_repo / ".config" / ".copier-managed-files.json").read_text(encoding="utf-8")) + final_manifest = _read_manifest(final_repo) srcs = {t["src"]: t for t in final_manifest["templates"]} assert "README.md" in srcs["https://github.com/org/base-template"]["managed_files"] assert "child_only.py" in srcs["https://github.com/org/child-template"]["managed_files"] diff --git a/tests/unit/copier_tasks/test_remove_precommit_hooks.py b/tests/unit/copier_tasks/test_remove_precommit_hooks.py index 9df47f85..23a07f1a 100644 --- a/tests/unit/copier_tasks/test_remove_precommit_hooks.py +++ b/tests/unit/copier_tasks/test_remove_precommit_hooks.py @@ -67,7 +67,7 @@ def test_When_multiple_hooks_match__Then_all_removed_and_count_reported(self, tm hook_id_regex = r"^\s*-\s+id:\s+check-" original = config_path.read_text(encoding="utf-8") - expected_removed = sum(1 for line in original.splitlines() if re.match(hook_id_regex, line)) + expected_removed = len([line for line in original.splitlines() if re.match(hook_id_regex, line) is not None]) assert expected_removed > 1 result = self._run_script(hook_id_regex=hook_id_regex, target_file=config_path) From af66896382d033185d5cddac1346b57a5af7f115 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 13 Aug 2026 21:25:01 +0000 Subject: [PATCH 08/20] fix pyrefly --- .config/pyrefly.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 46fd4b10..9de9a180 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -26,10 +26,10 @@ ignore-errors-in-generated-code = true # not sure yet how effective this is no-access = "ignore" # (already covered by ruff SLF001) # reportMissingTypeStubs=false in pyright untyped-import = "ignore" +unannotated-return = "ignore" # ruff ANN2xx already requires this, but with suppress-none-returning so that None-returning functions don't need the noise. pyrefly has no equivalent option---the error kind is all-or-nothing. [[sub-config]] -matches = "../tests/**" +matches = "../**/tests/**" [sub-config.errors] implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 -unannotated-return = "ignore" # test functions always return None; pyright never required this and annotating every test adds noise without catching anything From 92f2796760141c723916bf4c57954d49cfff25a0 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 12:49:13 +0000 Subject: [PATCH 09/20] testing --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 097f6fa7..08ebbe38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,8 @@ This project is a Copier template used to generate other copier templates. It is - When a test's final assertion is an absence (e.g., element is `null`, list is empty, modal is closed), include a prior presence assertion confirming the expected state existed before the action that removed it. A test whose only assertion is an absence check can pass vacuously if setup silently failed. - When asserting a mock or spy was called with specific arguments, always constrain as tightly as possible. In order of preference: (1) assert called exactly once with those args (`assert_called_once_with` in Python, `toHaveBeenCalledExactlyOnceWith` in Vitest/Jest); (2) if multiple calls are expected, assert the total call count and use a positional or last-call assertion (`nthCalledWith`, `lastCalledWith` / `assert_has_calls` with `call_args_list[n]`); (3) plain "called with at any point" (`toHaveBeenCalledWith`, `assert_called_with`) is a last resort only when neither the call count nor the call order can reasonably be constrained. - When asserting an exception is raised, verify the error message includes all key constructor arguments — not just one identifying field. This ensures the error message is fully populated and catches cases where arguments are swapped or missing. In Python: use the `match` parameter in `pytest.raises`. In TypeScript: use a regex or substring in `toThrow`, or catch and assert on error properties individually. +- Name tests with Given/When/Then where each clause means a specific thing: **When** names the single action under test plus the input that distinguishes this case; **Given** names only preconditions established before that action (fixtures, mocks, prior state) and is omitted when there are none; **Then** names the asserted outcome. +- A Given or When clause shared by every test in a group belongs on the enclosing scope (a pytest class, a `describe` block) rather than repeated in each test name. The chain from the outermost scope through the test name must read as one complete Given/When/Then, and a scope carrying a clause must state it explicitly (`TestWhenFooInvoked`, `describe("Given foo mocked to succeed")`). - When an exception has a fixed message with no variable data, prefer a specific exception subclass over `match` — the subclass type is the full assertion, and matching a hardcoded string duplicates the exception class without adding test value. In this case, suppress PT011 with an inline `# noqa: PT011` comment explaining why. - Structure each test body in this order, with a single blank line separating each section: 1. **Constants** — random/faker values and test data objects From f08c2ff970a7fc15c403ddb5e02d54c676105065 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 13:04:18 +0000 Subject: [PATCH 10/20] more --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 08ebbe38..90b62c84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ This project is a Copier template used to generate other copier templates. It is ### Frontend Testing -- Key `data-testid` selectors off unique IDs (e.g. UUIDs), not human-readable names which may collide or change. +- When a `data-testid` identifies one of many rendered entities, interpolate that entity's stable identifier as the dynamic value, not its display label — prefer an ID (`item.itemId`, `record.sha`) whenever the entity has one, since labels collide and change. Where the identifier *is* human-readable and no ID exists, that name is the key. - In DOM-based unit tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. Browser automation (e.g. Playwright) fails an ambiguous locator outright, so a unique `data-testid` looked up from the page is enough there. # Agent Implementations & Configurations From 6dba940adca449624680ca39665a7d8bb89a9206 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 14:30:06 +0000 Subject: [PATCH 11/20] better typeing --- ...e_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} index 54a606a7..1a5bebbf 100644 --- a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} +++ b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} @@ -34,7 +34,7 @@ def vcr_config() -> dict[str, list[str]]: "ignore_hosts": IGNORED_HOSTS, "filter_headers": ["User-Agent"], } - if ALLOWED_HOSTS: + if len(ALLOWED_HOSTS) > 0: cfg["allowed_hosts"] = ALLOWED_HOSTS return cfg @@ -54,7 +54,7 @@ def _logging_body_matcher(r1: VCRRequest, r2: VCRRequest) -> None: except AssertionError as err: tb_frames = traceback.extract_tb(err.__traceback__) if ( - not tb_frames or f"{os.sep}vcr{os.sep}" not in tb_frames[-1].filename + len(tb_frames) == 0 or f"{os.sep}vcr{os.sep}" not in tb_frames[-1].filename ): # if the assertion error came from something else in pytest, just rethrow it. Only log the diff if the assertion error came from within VCRpy itself raise try: @@ -83,7 +83,7 @@ def pytest_recording_configure( vcr.register_matcher("logging_body", _logging_body_matcher) vcr.match_on += ("logging_body",) # body is not included by default, but it seems relevant - def before_record_response(response: dict[str, str | dict[str, Any]]) -> dict[str, str | dict[str, Any]]: + def before_record_response(response: dict[str, str | dict[str, Any]]) -> dict[str, str | dict[str, Any]]: # pyrefly: ignore[explicit-any] # vcrpy hands this callback a recorded response whose sections (status/headers/body) hold different value types headers_to_filter = ( "Transfer-Encoding", "Date", From 9d6a9623bacc85f0828367f71f81b9133b4f66e5 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 15:14:09 +0000 Subject: [PATCH 12/20] pyrefyl --- .config/pyrefly.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 9de9a180..0dd45e07 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -33,3 +33,9 @@ matches = "../**/tests/**" [sub-config.errors] implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 +# Tests are self-asserting: a value that isn't what the test assumed fails the very next assertion, +# pointing at the exact line. The unknowns these rules flag also come almost entirely from mocking, where the value +# genuinely is `Any` and no upstream typing fix is possible --- `spy_return` is set dynamically on the mock, +# and `call_args[0]` is untyped by construction --- so enforcing here only buys narrowing ceremony at every spy. +unknown-variable-type = "ignore" +unknown-argument-type = "ignore" From 64507c374dfa7352ede14b7d2ca8b9ec458df33d Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 15:15:11 +0000 Subject: [PATCH 13/20] tidy --- .config/pyrefly.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 0dd45e07..0649a243 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -24,8 +24,7 @@ ignore-errors-in-generated-code = true # not sure yet how effective this is [errors] no-access = "ignore" # (already covered by ruff SLF001) -# reportMissingTypeStubs=false in pyright -untyped-import = "ignore" +untyped-import = "ignore" # equivalent of reportMissingTypeStubs=false in pyright unannotated-return = "ignore" # ruff ANN2xx already requires this, but with suppress-none-returning so that None-returning functions don't need the noise. pyrefly has no equivalent option---the error kind is all-or-nothing. [[sub-config]] From 98a9222018438fdc08b0f97af9632bd43d1d27c1 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 14 Aug 2026 18:47:38 +0000 Subject: [PATCH 14/20] Clarify AGENTS.md coverage and import rules Scope the branch-coverage guidelines to non-test code, since coverage never measures test files and collapsing a condition there hides nothing. Move the import rule out of Code Style into Tooling: it describes tool ownership rather than what code should look like, which puts it beside the `generated/` folder rule. Reword it to name the `import` statement explicitly, so it is not misread as being about a CLI command like the surrounding bullets, and to address the agent directly rather than relying on "manually". Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90b62c84..9d0ab908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,18 +8,17 @@ This project is a Copier template used to generate other copier templates. It is - Comments should be used very rarely. Code should generally express its intent. - Never write a one-line docstring — either the name is sufficient or the behavior warrants a full explanation. -- Don't sort or remove imports manually — pre-commit handles it. - Prefer keyword-only parameters (unless a very clear single-argument function): use `*` in Python signatures and destructured options objects in TypeScript. - When disabling a linting rule with an inline directive, provide a comment at the end of the line (or on the line above for tools that don't allow extra text after an inline directive) describing the reasoning for disabling the rule. - Avoid telling the type checker what a type is rather than letting it prove it. This includes type assertions (`as SomeType` in TypeScript, `cast()` in Python) and variable annotations that override inference. Prefer approaches that let the type checker verify the type itself: `isinstance`/`instanceof` narrowing, restructuring code so the correct type flows naturally, or using discriminated unions. When there is genuinely no alternative, add a comment explaining why the workaround is necessary and why it is safe. -- Avoid `||` (TypeScript) or `or` (Python) in `if`/`elif` conditions, and avoid `x in ['a', 'b']`-style membership tests in implementation code — coverage tools treat these as a single branch, silently masking untested paths and producing false 100% branch coverage. Use separate `if`/`elif` branches instead so each condition is independently covered. +- In non-test code — anything coverage measures — avoid `||` (TypeScript) or `or` (Python) in `if`/`elif` conditions, and avoid `x in ['a', 'b']`-style membership tests. Coverage tools treat these as a single branch, silently masking untested paths and producing false 100% branch coverage. Use separate `if`/`elif` branches instead so each condition is independently covered. Files under a test directory are exempt because coverage never measures them, so collapsing a condition there hides nothing. ### Python - Always include type hints. - Respect the pyrefly unused-call-result check; assign unneeded return values to `_` -- Prefer explicit `if`/`else` (or a `for` loop with `if`/`return`) over one-line forms that collapse branches: a ternary, `d.get(key, default)`, returning a boolean expression `return b > 5`/`return bool(b)`, or `any()`/`all()` over a generator in place of a loop. `coverage.py` tracks branches as line-to-line arcs, so a single-line expression hides the untaken path. -- When filtering logic combines multiple `and`-joined guards (e.g. a null check alongside a value check), prefer a loop with explicit `if`/`continue` branches over a single-line comprehension. A compound boolean filter on one line hides individual branches from line coverage — each guard condition should be its own statement so missing test cases are surfaced. +- In non-test code, prefer explicit `if`/`else` (or a `for` loop with `if`/`return`) over one-line forms that collapse branches: a ternary, `d.get(key, default)`, returning a boolean expression `return b > 5`/`return bool(b)`, or `any()`/`all()` over a generator in place of a loop. `coverage.py` tracks branches as line-to-line arcs, so a single-line expression hides the untaken path. +- In non-test code, when filtering logic combines multiple `and`-joined guards (e.g. a null check alongside a value check), prefer a loop with explicit `if`/`continue` branches over a single-line comprehension. A compound boolean filter on one line hides individual branches from line coverage — each guard condition should be its own statement so missing test cases are surfaced. ## Testing @@ -89,6 +88,7 @@ This project is a Copier template used to generate other copier templates. It is - Never use `pnpm --prefix ` or `uv --directory ` to target a different directory — these flags break the permission allow-list matcher the same way chained `cd &&` commands do. Instead, rely on the working directory already being correct (the cwd persists between Bash tool calls), or issue a plain `cd ` as a separate prior tool call to reposition before running the command. - Never use backslash line continuations in shell commands — always write the full command on a single line. Backslashes break the permission allow-list matcher. - **Never manually edit files in any `generated/` folder.** These files are produced by codegen tooling (typically Kiota) and any manual changes will be overwritten. If a generated file needs to change, update the source (e.g. the OpenAPI schema) and re-run the generator. +- Leave `import` statement ordering and unused-import removal to pre-commit — don't edit them yourself. ## Issue Tracking with bd (beads) From 1bfc22f0163c3809cd16a4b14222a83dc431b506 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 11:02:21 +0000 Subject: [PATCH 15/20] reorder import --- ...plate_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} index 1a5bebbf..92b990d4 100644 --- a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} +++ b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} @@ -5,8 +5,8 @@ import traceback from typing import Any import pytest -from vcr import VCR from vcr import matchers as _vcr_matchers +from vcr.config import VCR from vcr.request import Request as VCRRequest from vcr.util import read_body as _vcr_read_body From 54f55aec4baa208c92aecc679b2d2c19b0a4e2bd Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 11:11:24 +0000 Subject: [PATCH 16/20] lint --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5420bf2a..74d22dfc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 6e3dedef # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 82b20c3d # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f68f563b..009acd7d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -257,6 +257,7 @@ repos: (?x)^( .*/generated/graphql/.*| .*/generated/open[-_]api/.*| + .*__init__\.py| # flagging duplicate code for __all__ lists is a false positive template/.*| )$ args: From 7f4ed3c9cdb98b957399f8671a30d00d5a306a37 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 11:37:52 +0000 Subject: [PATCH 17/20] comments --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 74d22dfc..966c64d1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 82b20c3d # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): fd4e1850 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 009acd7d..32b11adf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -252,12 +252,14 @@ repos: hooks: - id: pylint name: pylint + # exclude any generated code + # flagging duplicate code for __all__ lists is a false positive # exclude the template files---duplication within them will be discovered during CI of that template instantiation exclude: | (?x)^( .*/generated/graphql/.*| .*/generated/open[-_]api/.*| - .*__init__\.py| # flagging duplicate code for __all__ lists is a false positive + .*__init__\.py| template/.*| )$ args: From 226ced0801d2a5b6d5340633dd41f75b50424002 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 12:56:22 +0000 Subject: [PATCH 18/20] Skip quoted-empty entries when parsing pnpm exclude patterns _parse_patterns checked for emptiness before stripping surrounding quotes, so an entry of "" or '' passed the non-empty guard and appended an empty string. That empty pattern was then written back through `pnpm config set minimumReleaseAgeExclude` as a malformed value with an empty field (e.g. "a,,b"). Reading it back drops the empty field, so it self-healed on the next run, but one malformed value was persisted to pnpm-workspace.yaml in the meantime. Strip the quotes before the emptiness check and guard on the normalized value. Behavior is unchanged for every non-empty input. Reported on LabAutomationAndScreening/copier-nuxt-python-intranet-app#209. Co-Authored-By: Claude Opus 5 (1M context) --- .../ensure_pnpm_minimum_release_age_exclude.py | 7 ++++--- ...test_ensure_pnpm_minimum_release_age_exclude.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py b/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py index 4a20a5f8..455efa0a 100644 --- a/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py +++ b/src/copier_base_template/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py @@ -10,10 +10,11 @@ def _parse_patterns(raw: str) -> list[str]: patterns: list[str] = [] for raw_pattern in raw.split(","): - stripped = raw_pattern.strip() - if stripped == "": + # Quotes are removed before the emptiness check so a quoted-empty entry doesn't yield an empty pattern. + pattern = raw_pattern.strip().strip('"').strip("'") + if pattern == "": continue - patterns.append(stripped.strip('"').strip("'")) + patterns.append(pattern) return patterns diff --git a/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py b/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py index c738230f..015e5b8c 100644 --- a/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py +++ b/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py @@ -51,6 +51,20 @@ def test_When_workspace_has_existing_patterns__Then_new_patterns_appended( parsed = yaml.safe_load(workspace.read_text(encoding="utf-8")) assert parsed["minimumReleaseAgeExclude"] == f"{existing},{new}" + def test_When_patterns_include_a_quoted_empty_entry__Then_only_the_real_pattern_is_set( + self, tmp_path: Path, faker: Faker + ) -> None: + plain = faker.name() + workspace = tmp_path / "pnpm-workspace.yaml" + _ = workspace.write_text("packages:\n - frontend\n", encoding="utf-8") + + result = self._run_script(patterns=f'"",{plain}', target_dir=tmp_path) + + parsed = yaml.safe_load(workspace.read_text(encoding="utf-8")) + + assert result.returncode == 0 + assert parsed["minimumReleaseAgeExclude"] == plain + def test_When_patterns_provided__Then_sets_value_in_workspace(self, tmp_path: Path, faker: Faker) -> None: scoped = f"{faker.name()}/{faker.name()}" plain = faker.name() From 09bc4e1ad286324f739e4897f8ee965d1d1d8e7e Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 14:23:50 +0000 Subject: [PATCH 19/20] Key tdd-review "The One" criterion on unrelatedness, not count The criterion listed "several Acts strung together under one name" as a detection signal, which contradicted the guidance that multi-step flows repeat the Act / State capture / Assertions cycle within one scenario. An agent reviewing an E2E suite written to that standard would flag every test in it. Split that into three distinct signals: multiple Acts in a unit test, where plurality genuinely is the smell; Acts drawn from unrelated scenarios, where unrelatedness rather than count is the smell; and assertions that don't trace back to an Act the test performs, which catches free-floating assertions even in a legitimate multi-step flow. Scope the count disclaimer to assertions, since it no longer holds for Acts in a unit test. Reported on LabAutomationAndScreening/copier-nuxt-python-intranet-app#209. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/tdd-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/commands/tdd-review.md b/.claude/commands/tdd-review.md index 305929f9..6606f248 100644 --- a/.claude/commands/tdd-review.md +++ b/.claude/commands/tdd-review.md @@ -59,7 +59,7 @@ For each test file, check against these criteria: |--------------|-------------------| | **The Liar** | `expect(true).toBe(true)`, empty test bodies, tests with no assertions | | **Excessive Setup** | >20 lines of arrange code, >5 mocks, deep nested object construction | -| **The One** | Assertions covering unrelated behaviors in a single test, or several Acts strung together under one name - count alone is not the signal, since one Arrange and Act can warrant many assertions | +| **The One** | Multiple Acts in a unit test, Acts from unrelated scenarios strung together under one name, or assertions that don't trace back to one of the test's Acts - assertion count alone is not the signal, since a single Act can warrant many assertions, and E2E or other multi-step flows legitimately chain several sequential Acts within one scenario | | **The Peeping Tom** | Testing private methods, asserting on internal state, tests that break on any refactor | | **The Slow Poke** | Real database/network calls, file I/O, hard-coded timeouts | From 7ad49a1d9db11e51d58406ac315e12b44f1ba8a9 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 16:04:50 +0000 Subject: [PATCH 20/20] Scope the AGENTS.md Playwright strictness claim to single-target locators The Frontend Testing bullet justified relaxing manual DOM query scoping in browser automation by saying Playwright "fails an ambiguous locator outright". Strict mode only raises for single-target operations; count(), all(), allTextContents() and nth() accept multi-match locators by design, so as written the claim could lead an agent to work around a count() call that would have been fine. Add the single-target qualifier rather than a caveat sentence about multi-element operations. The clause justifies the rule instead of stating it, and AGENTS.md is loaded into every agent's context. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9d0ab908..096f3981 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ This project is a Copier template used to generate other copier templates. It is ### Frontend Testing - When a `data-testid` identifies one of many rendered entities, interpolate that entity's stable identifier as the dynamic value, not its display label — prefer an ID (`item.itemId`, `record.sha`) whenever the entity has one, since labels collide and change. Where the identifier *is* human-readable and no ID exists, that name is the key. -- In DOM-based unit tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. Browser automation (e.g. Playwright) fails an ambiguous locator outright, so a unique `data-testid` looked up from the page is enough there. +- In DOM-based unit tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. Browser automation (e.g. Playwright) fails an ambiguous single-target locator outright, so a unique `data-testid` looked up from the page is enough there. # Agent Implementations & Configurations