From 8e12ef260300c1a64661947159f29ad10a096542 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 10:28:38 +0000 Subject: [PATCH 1/5] pull in --- .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 +- .config/.copier-answers.yml | 2 +- .config/_typos.toml | 2 - .config/pylintrc.toml | 2 +- .config/pyrefly.toml | 16 +- .config/ruff.toml | 1 + .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 6 +- .pre-commit-config.yaml | 27 +--- AGENTS.md | 14 +- .../openapi_schema_simplifier.py | 2 +- copier_template_resources/vcrpy_fixtures.py | 6 +- extensions/context.py | 16 +- pyproject.toml | 2 +- src/copier_tasks/copier_provenance.py | 146 ++++++++++++------ src/copier_tasks/remove_precommit_hooks.py | 16 +- template/.claude/commands/green.md | 5 +- template/.claude/commands/issue.md | 5 +- template/.claude/commands/red.md | 5 +- template/.claude/commands/refactor.md | 5 +- template/.claude/commands/spike.md | 5 +- template/.claude/commands/tdd-review.md | 2 +- template/.claude/commands/tdd.md | 5 +- template/.config/_typos.toml | 2 - template/.config/pylintrc.toml | 2 +- template/.config/pyrefly.toml | 16 +- template/.config/ruff.toml | 1 + template/.pre-commit-config.yaml | 27 +--- template/AGENTS.md | 14 +- .../copier_tasks/test_copier_provenance.py | 53 +++++-- .../test_remove_precommit_hooks.py | 2 +- uv.lock | 8 +- 38 files changed, 260 insertions(+), 189 deletions(-) diff --git a/.claude/commands/green.md b/.claude/commands/green.md index 7350b851..94ff71ca 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 c41d033a..2e71a0d6 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 d4f6cdf0..32fc6784 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 b8d6d1ba..578a1dd2 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 e7121b93..f1e0583f 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 8301c387..51b4ed2a 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 2be6d7ca..10c71626 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 diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 9426d4f9..e8ba04ee 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.0.151 +_commit: v0.0.151-14-g98a92220 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/.config/_typos.toml b/.config/_typos.toml index 254b9c8b..6aa84cef 100644 --- a/.config/_typos.toml +++ b/.config/_typos.toml @@ -13,8 +13,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 7edced58..9aa600e8 100644 --- a/.config/pylintrc.toml +++ b/.config/pylintrc.toml @@ -9,7 +9,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", diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 17aa02d6..cfd348d8 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -25,19 +25,23 @@ 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 [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]] -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 +# 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" diff --git a/.config/ruff.toml b/.config/ruff.toml index db5ea4bc..3f009c3d 100644 --- a/.config/ruff.toml +++ b/.config/ruff.toml @@ -50,6 +50,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 7692aee4..6cb3ee26 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -60,5 +60,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): bf3441ae # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 6df4cc30 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index c73a3133..1fc0dd98 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -14,9 +14,9 @@ import tempfile from pathlib import Path -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 7de32702..bb816c5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,7 +49,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] @@ -84,19 +84,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 @@ -111,7 +98,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] @@ -181,7 +168,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 @@ -226,18 +213,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 @@ -268,7 +255,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/AGENTS.md b/AGENTS.md index 819663c3..b489b6dc 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 @@ -33,6 +32,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 @@ -64,8 +65,8 @@ 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. +- 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 @@ -87,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) diff --git a/copier_template_resources/openapi_schema_simplifier.py b/copier_template_resources/openapi_schema_simplifier.py index f852355f..6e7b818e 100644 --- a/copier_template_resources/openapi_schema_simplifier.py +++ b/copier_template_resources/openapi_schema_simplifier.py @@ -41,7 +41,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/copier_template_resources/vcrpy_fixtures.py b/copier_template_resources/vcrpy_fixtures.py index 901ad92a..5dd9e67f 100644 --- a/copier_template_resources/vcrpy_fixtures.py +++ b/copier_template_resources/vcrpy_fixtures.py @@ -41,7 +41,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 @@ -61,7 +61,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: @@ -90,7 +90,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", diff --git a/extensions/context.py b/extensions/context.py index 7d64a608..41019b02 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -19,15 +19,15 @@ class ContextUpdater(ContextHook): def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just creating the dict self, context: dict[Any, Any] ) -> dict[Any, Any]: - context["uv_version"] = "0.12.1" - context["pnpm_version"] = "11.20.0" + context["uv_version"] = "0.12.3" + context["pnpm_version"] = "11.21.0" context["npm_version"] = "11.13.0" context["nvm_version"] = "0.40.5" context["pre_commit_version"] = "4.5.1" context["pytest_version"] = ">=9.1.1" context["pytest_randomly_version"] = ">=4.1.0" context["pytest_cov_version"] = ">=7.1.0" - context["copier_version"] = "==9.17.0" + context["copier_version"] = "==9.17.1" context["copier_template_extensions_version"] = "==0.3.3" context["sphinx_version"] = "9.0.4" context["pulumi_version"] = ">=3.245.0" @@ -42,20 +42,20 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pyinstaller_version"] = ">=6.20.0" context["setuptools_version"] = "80.7.1" context["strawberry_graphql_version"] = "==0.298.0" - 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 2893f9c8..5f83cf3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pytest-randomly>=4.1.0", "faker>=40.36.0", "pyrefly>=1.2.0", - "copier==9.17.0", + "copier==9.17.1", "copier-template-extensions==0.3.3" ] diff --git a/src/copier_tasks/copier_provenance.py b/src/copier_tasks/copier_provenance.py index 495e2ada..775c5c57 100644 --- a/src/copier_tasks/copier_provenance.py +++ b/src/copier_tasks/copier_provenance.py @@ -11,13 +11,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" @@ -77,7 +88,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. @@ -95,7 +106,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): @@ -106,15 +118,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"" @@ -164,7 +176,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 @@ -250,7 +262,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( @@ -263,17 +277,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( @@ -282,60 +300,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_tasks/remove_precommit_hooks.py b/src/copier_tasks/remove_precommit_hooks.py index a2ed8aaa..2854e023 100644 --- a/src/copier_tasks/remove_precommit_hooks.py +++ b/src/copier_tasks/remove_precommit_hooks.py @@ -15,7 +15,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: @@ -31,7 +33,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 @@ -39,10 +41,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/template/.claude/commands/green.md b/template/.claude/commands/green.md index 7350b851..94ff71ca 100644 --- a/template/.claude/commands/green.md +++ b/template/.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/template/.claude/commands/issue.md b/template/.claude/commands/issue.md index c41d033a..2e71a0d6 100644 --- a/template/.claude/commands/issue.md +++ b/template/.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/template/.claude/commands/red.md b/template/.claude/commands/red.md index d4f6cdf0..32fc6784 100644 --- a/template/.claude/commands/red.md +++ b/template/.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/template/.claude/commands/refactor.md b/template/.claude/commands/refactor.md index b8d6d1ba..578a1dd2 100644 --- a/template/.claude/commands/refactor.md +++ b/template/.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/template/.claude/commands/spike.md b/template/.claude/commands/spike.md index e7121b93..f1e0583f 100644 --- a/template/.claude/commands/spike.md +++ b/template/.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/template/.claude/commands/tdd-review.md b/template/.claude/commands/tdd-review.md index 8301c387..51b4ed2a 100644 --- a/template/.claude/commands/tdd-review.md +++ b/template/.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/template/.claude/commands/tdd.md b/template/.claude/commands/tdd.md index 2be6d7ca..10c71626 100644 --- a/template/.claude/commands/tdd.md +++ b/template/.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 diff --git a/template/.config/_typos.toml b/template/.config/_typos.toml index 9c6cd6e3..3891c99a 100644 --- a/template/.config/_typos.toml +++ b/template/.config/_typos.toml @@ -13,8 +13,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/template/.config/pylintrc.toml b/template/.config/pylintrc.toml index 7edced58..9aa600e8 100644 --- a/template/.config/pylintrc.toml +++ b/template/.config/pylintrc.toml @@ -9,7 +9,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", diff --git a/template/.config/pyrefly.toml b/template/.config/pyrefly.toml index 17aa02d6..cfd348d8 100644 --- a/template/.config/pyrefly.toml +++ b/template/.config/pyrefly.toml @@ -25,19 +25,23 @@ 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 [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]] -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 +# 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" diff --git a/template/.config/ruff.toml b/template/.config/ruff.toml index db5ea4bc..3f009c3d 100644 --- a/template/.config/ruff.toml +++ b/template/.config/ruff.toml @@ -50,6 +50,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/template/.pre-commit-config.yaml b/template/.pre-commit-config.yaml index 7de32702..bb816c5a 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.pre-commit-config.yaml @@ -49,7 +49,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] @@ -84,19 +84,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 @@ -111,7 +98,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] @@ -181,7 +168,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 @@ -226,18 +213,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 @@ -268,7 +255,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/template/AGENTS.md b/template/AGENTS.md index 885ef042..7ca5b0d4 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -8,18 +8,17 @@ This project is a Python library. - 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 @@ -33,6 +32,8 @@ This project is a Python library. - 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 @@ -64,8 +65,8 @@ This project is a Python library. ### 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. +- 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 @@ -87,6 +88,7 @@ This project is a Python library. - 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) diff --git a/tests/unit/copier_tasks/test_copier_provenance.py b/tests/unit/copier_tasks/test_copier_provenance.py index 7f3a8101..639bd879 100644 --- a/tests/unit/copier_tasks/test_copier_provenance.py +++ b/tests/unit/copier_tasks/test_copier_provenance.py @@ -8,6 +8,8 @@ import json import subprocess from pathlib import Path +from typing import NotRequired +from typing import TypedDict import pytest from faker import Faker @@ -17,6 +19,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. @@ -71,7 +92,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 @@ -442,7 +463,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" @@ -468,7 +489,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: @@ -491,7 +512,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 @@ -522,7 +543,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"] @@ -540,7 +561,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" @@ -559,7 +580,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) @@ -585,8 +606,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: @@ -606,8 +628,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: @@ -623,7 +646,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 @@ -660,7 +683,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 @@ -703,10 +726,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). @@ -733,7 +756,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"] @@ -748,7 +771,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 ea117b3c..675db8cd 100644 --- a/tests/unit/copier_tasks/test_remove_precommit_hooks.py +++ b/tests/unit/copier_tasks/test_remove_precommit_hooks.py @@ -74,7 +74,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) diff --git a/uv.lock b/uv.lock index 2099aee6..e3d2960f 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "copier" -version = "9.17.0" +version = "9.17.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama" }, @@ -39,9 +39,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]] @@ -60,7 +60,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 = "pyrefly", specifier = ">=1.2.0" }, From 3772223c05fe8ea9d835cf7fa124a0ce270b4e11 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 14:35:46 +0000 Subject: [PATCH 2/5] pylint --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 3 +++ copier_template_resources/vcrpy_fixtures.py | 2 +- template/.pre-commit-config.yaml | 3 +++ 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index e8ba04ee..a33dbc80 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.0.151-14-g98a92220 +_commit: v0.0.151-17-g7f4ed3c9 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6cb3ee26..278675ac 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -60,5 +60,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): 6df4cc30 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): ad6af682 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bb816c5a..69d39e12 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -259,11 +259,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| template/.*| )$ args: diff --git a/copier_template_resources/vcrpy_fixtures.py b/copier_template_resources/vcrpy_fixtures.py index 5dd9e67f..2609c797 100644 --- a/copier_template_resources/vcrpy_fixtures.py +++ b/copier_template_resources/vcrpy_fixtures.py @@ -12,8 +12,8 @@ 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 diff --git a/template/.pre-commit-config.yaml b/template/.pre-commit-config.yaml index bb816c5a..69d39e12 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.pre-commit-config.yaml @@ -259,11 +259,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| template/.*| )$ args: From caf4a4cb914d12e602adaa3bdf82241bb01409cb Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 14:37:45 +0000 Subject: [PATCH 3/5] hprasing --- .claude/commands/tdd-review.md | 2 +- .config/.copier-answers.yml | 2 +- template/.claude/commands/tdd-review.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/commands/tdd-review.md b/.claude/commands/tdd-review.md index 51b4ed2a..f34f3088 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 | diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index a33dbc80..4ea0f20c 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.0.151-17-g7f4ed3c9 +_commit: v0.0.151-19-g09bc4e1a _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/template/.claude/commands/tdd-review.md b/template/.claude/commands/tdd-review.md index 51b4ed2a..f34f3088 100644 --- a/template/.claude/commands/tdd-review.md +++ b/template/.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 ed74cee172b7c7401efed996539f26fdfc441704 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 17:28:50 +0000 Subject: [PATCH 4/5] plawyright --- .config/.copier-answers.yml | 2 +- AGENTS.md | 2 +- template/AGENTS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 4ea0f20c..67e68034 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.0.151-19-g09bc4e1a +_commit: v0.0.151-20-g7ad49a1d _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/AGENTS.md b/AGENTS.md index b489b6dc..b871f7e0 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 diff --git a/template/AGENTS.md b/template/AGENTS.md index 7ca5b0d4..3b46af25 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -66,7 +66,7 @@ This project is a Python library. ### 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 From 5c939e3fe621e7cab491c9e54725311106bb402d Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 17 Aug 2026 19:07:39 +0000 Subject: [PATCH 5/5] tag --- .config/.copier-answers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 67e68034..e39af6fc 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.0.151-20-g7ad49a1d +_commit: v0.0.152 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables