diff --git a/.claude/settings/models.jsonc b/.claude/settings/models.jsonc index d8918e1..25546fe 100644 --- a/.claude/settings/models.jsonc +++ b/.claude/settings/models.jsonc @@ -9,6 +9,7 @@ */ { "availableModels": [ + "fable", "sonnet", "opus", "haiku", diff --git a/.claude/settings/permissions/write.jsonc b/.claude/settings/permissions/edit.jsonc similarity index 76% rename from .claude/settings/permissions/write.jsonc rename to .claude/settings/permissions/edit.jsonc index 4ce0d81..dc1600d 100644 --- a/.claude/settings/permissions/write.jsonc +++ b/.claude/settings/permissions/edit.jsonc @@ -8,12 +8,13 @@ * ===================================================================================================== */ { - // Write permissions for specific files + // Edit permissions for specific files // This should only ever be running in a devcontainer, so pretty lenient permissions are allowed "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "allow": ["Write(/tmp/**/*)", "Write(/workspaces/**/tmp/**)", "Write(tmp/**)"], + // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix + "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Write(Taskfile.yaml)", "Write(.config/taskfiles/**)", "Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], }, } diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index 44eeed8..4d90562 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -2,7 +2,7 @@ name: address-pr-comments description: Addresses PR review comments by making code changes and posting replies. Takes a PR number or auto-detects from current branch. Use when the user wants to address review feedback, respond to PR comments, fix PR feedback, or says "address comments on PR X". user-invocable: true -allowed-tools: Bash, Read, Write, Edit, Grep, Glob, AskUserQuestion +allowed-tools: Bash, Read, Write, Edit, Grep, Glob, AskUserQuestion, Skill adapted-from: https://github.com/josh-gree/my-claude-skills --- @@ -190,9 +190,12 @@ Within a single Phase 2 invocation, the order below is strict. The invariants ap > **Never commit before the reply body is finalized with the user. Never push before all reply texts in the batch are finalized. Never post a reply before its commit has been pushed. Never skip the commit link in a code-change reply.** 1. For each queued **code change** (in order): - - Follow the `/create-issues` process: create a bd issue with a proper title, description (why this change, context from the PR comment), design (technical approach), and Given-When-Then acceptance criteria. Export after creating. + - Invoke the `Skill` tool with `skill: "create-issues"`, passing the reviewer's comment and the agreed change as `args`. Follow the process it returns to create a bd issue with a proper title, description (why this change, context from the PR comment), design (technical approach), and Given-When-Then acceptance criteria. Export after creating. - Mark the issue in progress - - If the change involves code: execute the TDD red/green/refactor cycle (`/red` → `/green` → `/refactor`) against the acceptance criteria. If the change is non-code (docs, markdown, config, scripts, etc.): make the change directly — TDD does not apply, but the remaining steps are identical. + - Decide first whether the change is testable code. **If it is not** — documentation, markdown, comments, config, shell scripts where the project has no harness for them, or any other change with no behaviour to assert — make the change directly and move to the next step. Do **not** invoke `red`, `green` or `refactor`, and do not invent a test to justify the change: TDD does not apply here. Everything after this step is identical either way. + - **If it is testable code**, work through the TDD cycle by invoking the `Skill` tool once per phase, in this order — `skill: "red"`, then `skill: "green"`, then `skill: "refactor"` — passing the bd issue ID and the acceptance criterion being driven as `args`. Repeat the three-phase cycle until every acceptance criterion is satisfied. + + **Each phase must be a real `Skill` tool call — not a description of one, and not your own recollection of what red/green/refactor mean.** Those skills carry project-specific rules that general TDD practice does not include, and they are re-read fresh each cycle so later cycles do not drift from earlier ones. Writing a test or an implementation without a preceding `Skill` call for that phase is a workflow violation. - **Finalize the reply body with the user — before committing.** Draft the reply text (everything except the commit link) — **do NOT include the AI attribution footer; `check-footer.py` appends it**. Write to ``, tell the user the absolute path (per [Conventions](#conventions)), and go through the approve/edit loop with the user until they approve. Do not proceed until the user explicitly confirms the text. Leave a clear `[COMMIT LINK]` placeholder where the link will go. Use AskUserQuestion with this wording (do NOT say "Post now" — the reply is queued for posting after the push, not posted immediately): @@ -252,6 +255,8 @@ Summarise what was done: - Skip comments without asking - Leave uncommitted changes - Include the AI attribution footer in generated reply text — `check-footer.py` is solely responsible for it +- Write tests or implementation from your own knowledge of TDD — when a change is testable code, `red`, `green` and `refactor` must each be invoked through the `Skill` tool +- Run the TDD cycle on a change that has no behaviour to assert — docs, markdown, comments, config and unharnessed shell scripts are made directly ## Handling Common Scenarios @@ -273,7 +278,7 @@ Discuss with user, then post a reply explaining the reasoning if they want to pu - [ ] If `--resume`: build inventory, classify reply drafts (placeholder vs finalized vs orphan), prompt user for resume strategy - [ ] Fetch and display comments - [ ] Phase 1: collect decisions for all comments (reply-only comments posted in Phase 1) -- [ ] Phase 2: implement code changes, one commit per comment +- [ ] Phase 2: invoke `create-issues` via the `Skill` tool for every queued item; add `red` → `green` → `refactor` only for items that are testable code; one commit per comment - [ ] Push all commits in a single push - [ ] Post code-change replies with commit links after push - [ ] Report what was done diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 255dd59..18363bf 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.161 +_commit: v0.1.5 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Creating libraries in the VueJS ecosystem diff --git a/.config/.copier-managed-files.json b/.config/.copier-managed-files.json index 72ca3e2..c9ca7dd 100644 --- a/.config/.copier-managed-files.json +++ b/.config/.copier-managed-files.json @@ -36,8 +36,8 @@ ".claude/settings/models.jsonc", ".claude/settings/permissions/additional-dirs.jsonc", ".claude/settings/permissions/bash.jsonc", + ".claude/settings/permissions/edit.jsonc", ".claude/settings/permissions/read.jsonc", - ".claude/settings/permissions/write.jsonc", ".claude/skills/address-pr-comments/SKILL.md", ".claude/skills/address-pr-comments/check-footer.py", ".claude/skills/address-pr-comments/commit-link.py", @@ -98,7 +98,9 @@ "Taskfile.yaml", "biome.jsonc", "copier.yml", - "copier_template_resources/eslint.shared.mjs", + "copier_template_resources/eslint/eslint.shared.mjs", + "copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs", + "copier_template_resources/eslint/istanbul-ignore-if-must-throw.spec.ts", "copier_template_resources/openapi_schema_simplifier.py", "extensions/context.py", "pyproject.toml", @@ -108,6 +110,7 @@ "src/copier_tasks/__init__.py", "src/copier_tasks/copier_provenance.py", "src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py", + "src/copier_tasks/normalize_pnpm_minimum_release_age_exclude.py", "src/copier_tasks/remove_precommit_hooks.py", "template/.claude/.beads/.gitignore", "template/.claude/.beads/config.yaml", @@ -142,8 +145,8 @@ "template/.claude/settings/models.jsonc", "template/.claude/settings/permissions/additional-dirs.jsonc", "template/.claude/settings/permissions/bash.jsonc", + "template/.claude/settings/permissions/edit.jsonc", "template/.claude/settings/permissions/read.jsonc", - "template/.claude/settings/permissions/write.jsonc", "template/.claude/skills/address-pr-comments/SKILL.md", "template/.claude/skills/address-pr-comments/check-footer.py", "template/.claude/skills/address-pr-comments/commit-link.py", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b17c3f3..7650756 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,15 +22,15 @@ "extensions": [ // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.5", + "coderabbit.coderabbit-vscode@0.21.7", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905", - "anthropic.claude-code@2.1.252", + "anthropic.claude-code@2.1.273", // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.2.9002", + "meta.pyrefly@1.3.1", "ms-vscode-remote.remote-containers@0.414.0", "charliermarsh.ruff@2026.68.0", @@ -67,5 +67,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): c4ef9d1e # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): a4afbd6c # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 422ce73..0ed4d17 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -15,9 +15,9 @@ import tempfile from pathlib import Path -UV_VERSION = "0.12.6" -PNPM_VERSION = "11.22.0" -COPIER_VERSION = "==9.17.1" +UV_VERSION = "0.12.15" +PNPM_VERSION = "12.4.2" +COPIER_VERSION = "==9.18.2" COPIER_TEMPLATE_EXTENSIONS_VERSION = "==0.3.3" PRE_COMMIT_VERSION = "4.6.2" TASK_VERSION = "3.53.1" diff --git a/.github/actions/install_deps/action.yml b/.github/actions/install_deps/action.yml index b91d8b3..a995b8f 100644 --- a/.github/actions/install_deps/action.yml +++ b/.github/actions/install_deps/action.yml @@ -16,8 +16,8 @@ inputs: description: Node version to install required: false # if this is set, then assume you want node dependencies installed default: 'notUsing' - package-json-file: - description: Relative path to a package.json to read the pnpm version from (its `packageManager` or `devEngines.packageManager` field). When unset, the pnpm version pinned by the template is used instead. + working-directory: + description: Relative path to the directory holding the package.json to read the pnpm version from (its `packageManager` or `devEngines.packageManager` field). When unset, the pnpm version pinned by the template is used instead. required: false default: 'notUsing' install-deps: @@ -68,43 +68,52 @@ runs: with: python-version: ${{ env.PYTHON_VERSION }} - - name: Check for a pnpm lock file # pnpm/setup can only cache the store when there's a lock file at the repo root to key the cache off of - id: pnpm-lock-probe + - name: Locate the pnpm project # pnpm/setup keys the store cache off a lock file, so it can only cache when one exists in the directory the project lives in + id: pnpm-project if: ${{ inputs.node-version != 'notUsing' }} + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | - if [ -f pnpm-lock.yaml ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" + if [ "$WORKING_DIRECTORY" = "notUsing" ]; then + project_dir="." else - echo "exists=false" >> "$GITHUB_OUTPUT" + project_dir="$WORKING_DIRECTORY" + fi + echo "dir=$project_dir" >> "$GITHUB_OUTPUT" + if [ -f "$project_dir/pnpm-lock.yaml" ]; then + echo "lock_exists=true" >> "$GITHUB_OUTPUT" + else + echo "lock_exists=false" >> "$GITHUB_OUTPUT" fi shell: bash - name: Setup pnpm and node # this action installs both pnpm and the node runtime, so actions/setup-node is not needed if: ${{ inputs.node-version != 'notUsing' }} - uses: pnpm/setup@v2.0.2 + uses: pnpm/setup@v2.1.0 with: - # an empty version makes pnpm/setup read it from package-json-file instead. Passing both a version and a package.json with a conflicting `packageManager` field is a hard error in pnpm/setup. - version: ${{ inputs.package-json-file == 'notUsing' && '11.22.0' || '' }} - package-json-file: ${{ inputs.package-json-file == 'notUsing' && 'package.json' || inputs.package-json-file }} + # an empty version makes pnpm/setup read it from the manifest in working-directory instead. Passing both a version and a package.json with a conflicting `packageManager` field is a hard error in pnpm/setup. + version: ${{ inputs.working-directory == 'notUsing' && '12.4.2' || '' }} + working-directory: ${{ steps.pnpm-project.outputs.dir }} + # cache-dependency-path is left at its default, which is the lock file inside working-directory -- the same one the probe above checks runtime: node@${{ inputs.node-version }} - cache: ${{ steps.pnpm-lock-probe.outputs.exists }} + cache: ${{ steps.pnpm-project.outputs.lock_exists }} install: false # dependency installation is handled by manual-setup-deps.py, which knows about every environment in the repo - name: Install tooling # the funky syntax is github action ternary # --no-node is always passed because the only node tooling this script installs is pnpm, which pnpm/setup above already installed - run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager && '--skip-installing-ssm-plugin' || '' }} + run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} shell: pwsh - name: OIDC Auth for CodeArtifact if: ${{ inputs.code-artifact-auth-role-name != 'no-code-artifact' }} - uses: aws-actions/configure-aws-credentials@v6.2.3 + uses: aws-actions/configure-aws-credentials@v6.3.0 with: role-to-assume: arn:aws:iam::${{ inputs.code-artifact-auth-role-account-id }}:role/${{ inputs.code-artifact-auth-role-name }} aws-region: ${{ inputs.code-artifact-auth-region }} - name: Install dependencies # the funky syntax is github action ternary - if: ${{ inputs.install-deps }} - run: python .devcontainer/manual-setup-deps.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} ${{ inputs.node-version == 'notUsing' && '--no-node' || '' }} ${{ inputs.skip-updating-devcontainer-hash && '--skip-updating-devcontainer-hash' || '' }} ${{ inputs.skip-installing-pulumi-cli && '--skip-installing-pulumi-cli' || '' }} + if: ${{ inputs.install-deps == 'true' }} + run: python .devcontainer/manual-setup-deps.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} ${{ inputs.node-version == 'notUsing' && '--no-node' || '' }} ${{ inputs.skip-updating-devcontainer-hash == 'true' && '--skip-updating-devcontainer-hash' || '' }} ${{ inputs.skip-installing-pulumi-cli == 'true' && '--skip-installing-pulumi-cli' || '' }} shell: pwsh diff --git a/.github/reusable_workflows/build-docker-image.yaml b/.github/reusable_workflows/build-docker-image.yaml index 19d1738..8fcb651 100644 --- a/.github/reusable_workflows/build-docker-image.yaml +++ b/.github/reusable_workflows/build-docker-image.yaml @@ -179,13 +179,13 @@ jobs: - name: Set up Docker Buildx if: ${{ (inputs.save-as-artifact && inputs.push-role-name == 'no-push') || steps.image-status.outputs.status == 'notfound' }} - uses: docker/setup-buildx-action@v4.0.0 + uses: docker/setup-buildx-action@v4.4.1 with: - version: v0.33.0 + version: v0.37.1 - name: Build Docker Image if: ${{ (inputs.save-as-artifact && inputs.push-role-name == 'no-push') || steps.image-status.outputs.status == 'notfound' }} - uses: docker/build-push-action@v7.1.0 + uses: docker/build-push-action@v7.4.0 with: context: ${{ inputs.context }} build-contexts: ${{ inputs.build-contexts }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f8d958..efd504e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -257,16 +257,16 @@ jobs: if: always() steps: - name: fail if prior job failure + # Iterating the needs context keeps this gate from drifting out of sync with the job list above + env: + NEEDS: ${{ toJSON(needs) }} run: | - success_pattern="^(skipped|success)$" # these are the possibilities: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context - - if [[ ! "${{ needs.get-values.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.confirm-on-tagged-copier-template.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.check-skip-duplicate.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.pre-commit.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.unit-test.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.copier-create-verification-matrix.result }}" =~ $success_pattern ]]; then - echo "❌ One or more jobs did not finish with skipped or success" + # these are the possibilities: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context + not_ok=$(jq -r 'to_entries[] | select(.value.result | test("^(skipped|success)$") | not) | .key' <<<"$NEEDS") + + if [[ -n "$not_ok" ]]; then + echo "❌ One or more jobs did not finish with skipped or success:" + echo "$not_ok" exit 1 fi echo "✅ All jobs finished with skipped or success" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ac6b25..fe00821 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -154,6 +154,8 @@ repos: .*tsconfig.json| )$ - id: check-yaml + # some tools (e.g. pnpm) create multiple-document YAML streams + args: [--allow-multiple-documents] # .copier-answers.yml is an autogenerated YAML file by Copier that throws 'found unhashable key' errors # Helm charts contain other non-YAML markers that cause errors here. they should be checked with helm-lint instead # VCRpy cassettes sometimes contain non-YAML markers that cause errors---but they're autogenerated anyway diff --git a/copier.yml b/copier.yml index bc8a318..f3e4fb2 100644 --- a/copier.yml +++ b/copier.yml @@ -90,6 +90,28 @@ pnpm_minimum_release_age_exclude: default: "" +_migrations: + # PNPM12-MIGRATION -- this whole block is transient: it carries repos across the pnpm 11 -> 12 boundary and + # nothing else. Delete both entries (and with them `_migrations` entirely, if nothing else has been added) + # once every downstream repo has taken an update that ran them. Grep PNPM12-MIGRATION for the rest. + # Both of these run in the `before` stage, while the repo's own pnpm-workspace.yaml is still on disk and + # before anything in the update reaches for pnpm. pnpm 12 cannot load a manifest whose + # minimumReleaseAgeExclude is a scalar -- it fails every pnpm command, `pnpm --version` included -- so the + # value has to be a list before the pnpm 12 install below, and `pnpm config` cannot do the rewrite itself. + - command: python3 '{{ _copier_conf.src_path }}/src/copier_tasks/normalize_pnpm_minimum_release_age_exclude.py' + when: "{{ _stage == 'before' }}" + # The devcontainer's pnpmVersion only takes effect on a rebuild, so an update applied from an existing + # container still has the old pnpm on PATH while the tasks that read pnpm 12 settings run. The version is + # hardcoded rather than templated because this migration exists to cross the pnpm 11 -> 12 boundary, and + # the check keeps it from downgrading a container that is already on 12 or newer. + - command: | + installed_major="$(pnpm --version 2>/dev/null | cut -d. -f1)" + if [ -z "$installed_major" ] || [ "$installed_major" -lt 12 ]; then + echo "Installing pnpm 12.4.2 (found '${installed_major:-none}') so pnpm 12 workspace settings can be read before a devcontainer rebuild..." + npm install -g pnpm@12.4.2 + fi + when: "{{ _stage == 'before' }}" + _tasks: - command: | python3 '{{ _copier_conf.src_path }}/src/copier_tasks/remove_precommit_hooks.py' \ diff --git a/copier_template_resources/eslint.shared.mjs b/copier_template_resources/eslint/eslint.shared.mjs similarity index 71% rename from copier_template_resources/eslint.shared.mjs rename to copier_template_resources/eslint/eslint.shared.mjs index 6bbb466..74dc1e4 100644 --- a/copier_template_resources/eslint.shared.mjs +++ b/copier_template_resources/eslint/eslint.shared.mjs @@ -7,8 +7,60 @@ * but if the change should be shared with other projects, please backport it to the template repo. * ===================================================================================================== */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + import vitest from "@vitest/eslint-plugin"; +// Consumers render this file at different depths below the repo root, so no fixed number of `../` +// reaches `.config/eslint-rules/` for all of them. +function findLocalRulesDir() { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = path.join(dir, ".config", "eslint-rules"); + if (fs.statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true) return candidate; + if (fs.existsSync(path.join(dir, ".git"))) { + throw new Error(`Could not find .config/eslint-rules under the project root ${dir}`); + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not find .config/eslint-rules above ${import.meta.url}`); + } + dir = parent; + } +} + +async function loadLocalRules() { + const rulesDir = findLocalRulesDir(); + const entries = fs.readdirSync(rulesDir).filter((entry) => entry.endsWith(".mjs")); + const rules = {}; + for (const entry of entries) { + const ruleName = entry.slice(0, -".mjs".length); + const module = await import(pathToFileURL(path.join(rulesDir, entry)).href); + if (module.default === undefined) { + throw new Error(`Local ESLint rule module ${entry} has no default export`); + } + rules[ruleName] = module.default; + } + return rules; +} + +const localRules = await loadLocalRules(); + +/** @type {import("eslint").Linter.Config} */ +export const istanbulIgnoreIfMustThrowConfig = { + files: ["**/*.{ts,vue}"], + plugins: { + local: { + rules: localRules, + }, + }, + rules: { + "local/istanbul-ignore-if-must-throw": "error", + }, +}; + export const ignoresConfig = { ignores: [ "**/generated/**", // ignore linting autogenerated files @@ -96,4 +148,11 @@ export const e2eConfig = { }, }; -export const sharedConfigs = [ignoresConfig, vueScriptLangConfig, noJsConfig, vitestConfig, e2eConfig]; +export const sharedConfigs = [ + ignoresConfig, + vueScriptLangConfig, + noJsConfig, + vitestConfig, + e2eConfig, + istanbulIgnoreIfMustThrowConfig, +]; diff --git a/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs new file mode 100644 index 0000000..917c5e2 --- /dev/null +++ b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs @@ -0,0 +1,138 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +/* + * Enforces the defensive-assertion contract for coverage-ignored branches: + * + * A branch marked `istanbul ignore if` (or `istanbul ignore next` when it sits on + * an `if`) must throw: per ESLint's code path analysis, the end of the branch is + * unreachable and no reachable return/break/continue inside it targets a construct + * outside it. Coverage-ignoring a guard means "this is unreachable"; a silent + * `return` there hides a real bug instead of surfacing it. If the absence is a + * legitimate state rather than a contract violation, the author opts out with + * `return-ok ` in the ignore comment (a colon after the token is optional) — + * "cannot happen" is what the throw is for, not what the escape is for. The reason is + * required rather than conventional, since a bare `return-ok` is indistinguishable + * from silencing the guard. + * Ignore comments on anything other than an `if` are left alone — the rule + * only makes a claim about branches whose shape it can verify. + */ + +const IGNORE_IF_OR_NEXT = /istanbul ignore (?:if|next)\b/; +// `return-ok` contains a hyphen, so `\b` would accept one as a boundary and read the token out of +// `not-return-ok`; the delimiter must exclude hyphens on both sides. +const RETURN_OK = /(?`. The escape claims this branch is a state the code tolerates rather than a violated invariant, and that claim is the reviewer's only evidence the guard was not simply silenced.", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const segmentStacks = []; + // Ignored ifs can nest; depth-first traversal keeps the innermost one on top. + const branchStack = []; + + function isReachable() { + return [...segmentStacks.at(-1)].some((segment) => segment.reachable); + } + + function report(entry) { + if (entry.reported) return; + entry.reported = true; + context.report({ node: entry.node, messageId: "mustThrow" }); + } + + function onSilentExit(node) { + const entry = branchStack.at(-1); + // Depth check keeps exits inside a nested function (its own code path) from counting. + if (entry === undefined || entry.depth !== segmentStacks.length) return; + if (isReachable() && leavesBranch(node, entry.consequent)) report(entry); + } + + return { + onCodePathStart() { + segmentStacks.push(new Set()); + }, + onCodePathEnd() { + segmentStacks.pop(); + }, + onCodePathSegmentStart(segment) { + segmentStacks.at(-1).add(segment); + }, + onCodePathSegmentEnd(segment) { + segmentStacks.at(-1).delete(segment); + }, + onUnreachableCodePathSegmentStart(segment) { + segmentStacks.at(-1).add(segment); + }, + onUnreachableCodePathSegmentEnd(segment) { + segmentStacks.at(-1).delete(segment); + }, + IfStatement(node) { + const leading = sourceCode.getCommentsBefore(node); + const ignoreComment = leading.find((comment) => IGNORE_IF_OR_NEXT.test(comment.value)); + if (ignoreComment === undefined) return; + if (RETURN_OK.test(ignoreComment.value)) { + if (RETURN_OK_WITH_REASON.test(ignoreComment.value)) return; + context.report({ node, messageId: "escapeNeedsReason" }); + return; + } + branchStack.push({ node, consequent: node.consequent, depth: segmentStacks.length, reported: false }); + }, + ":statement:exit"(node) { + if (node !== branchStack.at(-1)?.consequent) return; + const entry = branchStack.pop(); + // ESLint applies a throw/return/break/continue to the code path only after emitting that + // node's :exit, so for a braceless consequent the segments seen here are still the ones + // from before it. A bare throw satisfies the rule; a bare silent exit was already judged + // by onSilentExit on entry. + const { type } = entry.consequent; + if (type === "ThrowStatement" || SILENT_EXIT_TYPES.has(type)) return; + if (isReachable()) report(entry); + }, + ReturnStatement: onSilentExit, + BreakStatement: onSilentExit, + ContinueStatement: onSilentExit, + }; + }, +}; diff --git a/copier_template_resources/eslint/istanbul-ignore-if-must-throw.spec.ts b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.spec.ts new file mode 100644 index 0000000..ca2eb31 --- /dev/null +++ b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.spec.ts @@ -0,0 +1,426 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +import { RuleTester } from "eslint"; +import { describe, it } from "vitest"; +import rule from "../../../../.config/eslint-rules/istanbul-ignore-if-must-throw.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: "latest", sourceType: "module" }, +}); + +// RuleTester.run registers its own describe/it blocks and must be called at module top level, not inside a hook. +// eslint-disable-next-line vitest/require-hook +ruleTester.run("istanbul-ignore-if-must-throw", rule, { + valid: [ + { + name: "braceless throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (typeof x !== "string") throw new Error("bad"); + return x; + }`, + }, + { + name: "block throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + throw new Error("bad"); + } + }`, + }, + { + name: "silent return allowed with return-ok escape carrying a reason", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok: absence is valid */ + if (!x) return; + }`, + }, + { + name: "silent return allowed with return-ok escape whose reason has no colon", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok absence is valid */ + if (!x) return; + }`, + }, + { + name: "if without an istanbul ignore comment is untouched", + code: `function f(x) { + if (!x) return; + }`, + }, + { + name: "istanbul ignore next on a non-if statement is left alone", + code: `/* istanbul ignore next -- @preserve */ + function unreachable() { + return 1; + }`, + }, + { + name: "istanbul ignore else is left alone", + code: `function f(x) { + /* istanbul ignore else -- @preserve */ + if (!x) { + doSomething(); + } else { + doOther(); + } + }`, + }, + { + name: "nested if/else where both branches always throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) { + throw new Error("null"); + } else { + throw new Error("bad"); + } + } + }`, + }, + { + name: "non-exiting nested if followed by a real throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) { + logIt(); + } + throw new Error("bad"); + } + }`, + }, + { + name: "try and catch both always throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + try { + throw new Error("a"); + } catch { + throw new Error("b"); + } + } + }`, + }, + { + name: "finally always throws regardless of the try block", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + try { + doStuff(); + } finally { + throw new Error("bad"); + } + } + }`, + }, + { + name: "switch with a default where every case always throws", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + switch (x) { + case 1: + throw new Error("one"); + default: + throw new Error("other"); + } + } + }`, + }, + { + name: "break inside switch cases is absorbed, real throw follows", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + switch (x) { + case 1: + doStuff(); + break; + default: + doOther(); + } + throw new Error("bad"); + } + }`, + }, + { + name: "return inside a nested function belongs to that function, real throw follows", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + const describe = () => { + return "missing"; + }; + throw new Error(describe()); + } + }`, + }, + { + name: "break inside a nested loop is absorbed, real throw follows", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + for (const item of items) { + if (bad(item)) break; + } + throw new Error("bad"); + } + }`, + }, + { + name: "nested ignored ifs that both throw", + code: `function f(a, b) { + /* istanbul ignore if -- @preserve */ + if (!a) { + /* istanbul ignore if -- @preserve */ + if (!b) throw new Error("b"); + throw new Error("a"); + } + }`, + }, + { + name: "nested return-ok if whose break stays inside the outer branch", + code: `function f(a, items) { + /* istanbul ignore if -- @preserve */ + if (!a) { + for (const item of items) { + /* istanbul ignore if -- @preserve return-ok: stop scanning */ + if (!item) break; + } + throw new Error("a"); + } + }`, + }, + ], + invalid: [ + { + name: "nested ignored if throws but the outer one falls through after it", + code: `function f(a, b) { + /* istanbul ignore if -- @preserve */ + if (!a) { + /* istanbul ignore if -- @preserve */ + if (!b) throw new Error("b"); + log(a); + } + }`, + errors: [{ messageId: "mustThrow", line: 3 }], + }, + { + name: "nested ignored if returns silently; reported on the inner if only", + code: `function f(a, b) { + /* istanbul ignore if -- @preserve */ + if (!a) { + /* istanbul ignore if -- @preserve */ + if (!b) return; + throw new Error("a"); + } + }`, + errors: [{ messageId: "mustThrow", line: 5 }], + }, + { + name: "nested return-ok if excuses itself but not the outer branch it escapes", + code: `function f(a, b) { + /* istanbul ignore if -- @preserve */ + if (!a) { + /* istanbul ignore if -- @preserve return-ok: b absent is fine */ + if (!b) return; + throw new Error("a"); + } + }`, + errors: [{ messageId: "mustThrow", line: 3 }], + }, + { + name: "every violating ignored if in a file is reported, a compliant one between them is not", + code: `function f(a, b, c) { + /* istanbul ignore if -- @preserve */ + if (!a) return; + /* istanbul ignore if -- @preserve */ + if (!b) throw new Error("b"); + /* istanbul ignore if -- @preserve */ + if (!c) { + log(c); + } + }`, + errors: [ + { messageId: "mustThrow", line: 3 }, + { messageId: "mustThrow", line: 7 }, + ], + }, + { + name: "silent return under istanbul ignore if", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) return; + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "return-ok escape with no reason at all", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok */ + if (!x) return; + }`, + errors: [{ messageId: "escapeNeedsReason" }], + }, + { + name: "return-ok escape whose colon is followed by nothing", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok: */ + if (!x) return; + }`, + errors: [{ messageId: "escapeNeedsReason" }], + }, + { + name: "hyphen-prefixed lookalike is not a return-ok escape", + code: `function f(x) { + /* istanbul ignore if -- @preserve not-return-ok: absence is valid */ + if (!x) return; + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "hyphen-suffixed lookalike is not a return-ok escape", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok-ish */ + if (!x) return; + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "reasonless return-ok on a branch that throws anyway", + code: `function f(x) { + /* istanbul ignore if -- @preserve return-ok */ + if (!x) throw new Error("bad"); + }`, + errors: [{ messageId: "escapeNeedsReason" }], + }, + { + name: "block that does not end in throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + doSomething(); + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "silent return under istanbul ignore next on an if", + code: `function f(x) { + /* istanbul ignore next -- @preserve */ + if (!x) return; + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "recoverable return reachable before a final throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) return; + throw new Error("bad"); + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "nested if throws but its else silently returns", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) { + throw new Error("null"); + } else { + return; + } + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "nested block statement returns silently before a later throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + { + return; + } + throw new Error("bad"); + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "recoverable break reachable before a final throw", + code: `function f(x) { + for (;;) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) break; + throw new Error("bad"); + } + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "recoverable continue reachable before a final throw", + code: `function f(x) { + for (;;) { + /* istanbul ignore if -- @preserve */ + if (!x) { + if (x === null) continue; + throw new Error("bad"); + } + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "return inside a nested loop reaches past a later throw", + code: `function f(x) { + /* istanbul ignore if -- @preserve */ + if (!x) { + for (const item of items) { + if (bad(item)) return; + } + throw new Error("bad"); + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + { + name: "continue inside a switch case is not absorbed by the switch", + code: `function f(x) { + for (;;) { + /* istanbul ignore if -- @preserve */ + if (!x) { + switch (x) { + case 1: + continue; + default: + break; + } + throw new Error("bad"); + } + } + }`, + errors: [{ messageId: "mustThrow" }], + }, + ], +}); diff --git a/extensions/context.py b/extensions/context.py index f06b20b..7d0b313 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -19,51 +19,51 @@ 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.6" - context["pnpm_version"] = "11.22.0" + context["uv_version"] = "0.12.15" + context["pnpm_version"] = "12.4.2" context["npm_version"] = "11.13.0" context["nvm_version"] = "0.40.5" context["pre_commit_version"] = "4.6.2" context["task_version"] = "3.53.1" context["pytest_version"] = ">=9.1.1" - context["pytest_randomly_version"] = ">=4.1.0" + context["pytest_randomly_version"] = ">=5.0.0" context["pytest_cov_version"] = ">=7.1.0" - context["copier_version"] = "==9.17.1" + context["copier_version"] = "==9.18.2" context["copier_template_extensions_version"] = "==0.3.3" context["sphinx_version"] = "9.0.4" - context["pulumi_version"] = ">=3.245.0" - context["pulumi_aws_version"] = ">=7.32.0" - context["pulumi_aws_native_version"] = ">=1.68.0" + context["pulumi_version"] = ">=3.261.0" + context["pulumi_aws_version"] = ">=7.44.0" + context["pulumi_aws_native_version"] = ">=1.77.0" context["pulumi_command_version"] = ">=1.2.1" context["pulumi_github_version"] = ">=6.13.1" context["pulumi_okta_version"] = ">=6.6.0" - context["boto3_version"] = ">=1.42.92" + context["boto3_version"] = ">=1.43.88" context["ephemeral_pulumi_deploy_version"] = ">=0.0.7" - context["pydantic_version"] = ">=2.13.4" - context["pyinstaller_version"] = ">=6.22.2" + context["pydantic_version"] = ">=2.13.5" + context["pyinstaller_version"] = ">=6.22.3" context["setuptools_version"] = "80.7.1" context["strawberry_graphql_version"] = "==0.298.0" context["fastapi_version"] = ">=0.141.1" context["fastapi_offline_version"] = ">=1.7.7" context["starlette_version"] = ">=1.6.0" - context["uvicorn_version"] = ">=0.52.3" + context["uvicorn_version"] = ">=0.53.0" 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.5.3" + context["uuid_utils_version"] = ">=1.0.0" + context["syrupy_version"] = ">=6.1.1" context["structlog_version"] = ">=26.1.0" context["httpx_version"] = ">=0.28.1" - context["httpx2_version"] = ">=2.12.0" - context["python_kiota_bundle_version"] = ">=1.11.6" + context["httpx2_version"] = ">=2.13.0" + context["python_kiota_bundle_version"] = ">=1.12.3" 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" context["pytest_reserial_version"] = ">=0.6.1" - context["python_faker_version"] = ">=40.37.0" - context["mutmut_version"] = ">=3.7.0" - context["pyrefly_version"] = ">=1.2.0" + context["python_faker_version"] = ">=40.39.0" + context["mutmut_version"] = ">=3.8.0" + context["pyrefly_version"] = ">=1.3.1" context["vacuum_openapi_version"] = "0.30.0" context["default_node_version"] = "24.11.1" @@ -111,24 +111,24 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["gha_upload_artifact"] = "v7.0.1" context["gha_download_artifact"] = "v8.0.1" context["gha_github_script"] = "v7.0.1" - context["gha_setup_buildx"] = "v4.0.0" - context["buildx_version"] = "v0.33.0" - context["gha_docker_build_push"] = "v7.1.0" - context["gha_configure_aws_credentials"] = "v6.2.3" + context["gha_setup_buildx"] = "v4.4.1" + context["buildx_version"] = "v0.37.1" + context["gha_docker_build_push"] = "v7.4.0" + context["gha_configure_aws_credentials"] = "v6.3.0" context["gha_amazon_ecr_login"] = "v2.1.7" - context["gha_setup_node"] = "v6.4.0" - context["gha_pnpm_setup"] = "v2.0.2" - context["gha_action_gh_release"] = "v3.0.0" - context["gha_codecov"] = "v7.0.0" + context["gha_setup_node"] = "v7.0.0" + context["gha_pnpm_setup"] = "v2.1.0" + context["gha_action_gh_release"] = "v3.0.3" + context["gha_codecov"] = "v7.1.0" context["gha_mutex"] = "1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10" - context["gha_pypi_publish"] = "v1.14.0" - context["gha_sleep"] = "v2.0.3" + context["gha_pypi_publish"] = "v1.14.2" + context["gha_sleep"] = "v2.0.4" context["gha_absaoss_k3d"] = "v2.4.0" - context["k3d_version"] = "v5.5.0" - context["gha_azure_setup_helm"] = "v5.0.0" - context["helm_version"] = "v3.18.3" + context["k3d_version"] = "v5.9.0" + context["gha_azure_setup_helm"] = "v5.0.1" + context["helm_version"] = "v3.22.0" context["gha_azure_setup_kubectl"] = "v5.1.0" - context["kubectl_version"] = "v1.36.0" + context["kubectl_version"] = "v1.37.0" context["gha_linux_runner"] = "ubuntu-24.04" context["gha_windows_runner"] = "windows-2025-vs2026" context["gha_short_timeout_minutes"] = "2" diff --git a/pyproject.toml b/pyproject.toml index 5d6832c..4ff77d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,10 @@ dependencies = [ # Managed by upstream template "pytest>=9.1.1", "pytest-cov>=7.1.0", - "pytest-randomly>=4.1.0", - "faker>=40.37.0", - "pyrefly>=1.2.0", - "copier==9.17.1", + "pytest-randomly>=5.0.0", + "faker>=40.39.0", + "pyrefly>=1.3.1", + "copier==9.18.2", "copier-template-extensions==0.3.3" ] diff --git a/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py b/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py index fb13b30..964a257 100644 --- a/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py +++ b/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py @@ -6,12 +6,14 @@ # but if the change should be shared with other projects, please backport it to the template repo. # ===================================================================================================== import argparse +import json import shutil import subprocess from pathlib import Path _EXIT_CODE_PNPM_NOT_FOUND = 1 _WORKSPACE_FILENAME = "pnpm-workspace.yaml" +_SETTING_NAME = "minimumReleaseAgeExclude" def _parse_patterns(raw: str) -> list[str]: @@ -25,6 +27,25 @@ def _parse_patterns(raw: str) -> list[str]: return patterns +def _existing_patterns(*, workspace_dir: Path) -> list[str]: + get_result = subprocess.run( # noqa: S603 -- every argument is a literal defined in this module, none come from user input + ["pnpm", "config", "--location", "project", "--json", "get", _SETTING_NAME], # noqa: S607 -- pnpm is a trusted tool, not user input + check=True, + capture_output=True, + text=True, + cwd=workspace_dir, + ) + parsed: object = json.loads(get_result.stdout) + if not isinstance(parsed, list): + return [] + patterns: list[str] = [] + for entry in parsed: + if not isinstance(entry, str): + continue + patterns.append(entry) + return patterns + + def ensure_minimum_release_age_exclude(*, workspace_dir: Path, patterns: list[str]) -> None: if shutil.which("pnpm") is None: print( # noqa: T201 -- copier task output must reach the user @@ -36,20 +57,10 @@ def ensure_minimum_release_age_exclude(*, workspace_dir: Path, patterns: list[st print(f"{workspace_dir / _WORKSPACE_FILENAME} not found; skipping.") # noqa: T201 -- copier task output must reach the user return - get_result = subprocess.run( - ["pnpm", "config", "--location", "project", "get", "minimumReleaseAgeExclude"], # noqa: S607 -- pnpm is a trusted tool, not user input - check=True, - capture_output=True, - text=True, - cwd=workspace_dir, - ) - raw_existing = get_result.stdout.strip() - existing: list[str] = [] - if raw_existing != "undefined": - existing = _parse_patterns(raw_existing) + existing = _existing_patterns(workspace_dir=workspace_dir) merged = existing + [p for p in patterns if p not in existing] _ = subprocess.run( # noqa: S603 -- merged patterns come from pnpm config get and CLI input, both trusted in this copier task context - ["pnpm", "config", "--location", "project", "set", "minimumReleaseAgeExclude", ",".join(merged)], # noqa: S607 -- pnpm is a trusted tool, not user input + ["pnpm", "config", "--location", "project", "--json", "set", _SETTING_NAME, json.dumps(merged)], # noqa: S607 -- pnpm is a trusted tool, not user input check=True, cwd=workspace_dir, ) diff --git a/src/copier_tasks/normalize_pnpm_minimum_release_age_exclude.py b/src/copier_tasks/normalize_pnpm_minimum_release_age_exclude.py new file mode 100644 index 0000000..ae39fb0 --- /dev/null +++ b/src/copier_tasks/normalize_pnpm_minimum_release_age_exclude.py @@ -0,0 +1,103 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +# PNPM12-MIGRATION -- this entire module is transient. It exists only to carry repos across the pnpm 11 -> 12 +# boundary, where minimumReleaseAgeExclude changed from a comma-delimited string to a YAML sequence. Once every +# downstream repo has taken an update that runs it, delete this file, its symlink under +# `template/src/copier_tasks/`, its test module, and the migration that invokes it. Grep PNPM12-MIGRATION. +import argparse +from pathlib import Path + +_WORKSPACE_FILENAME = "pnpm-workspace.yaml" +_SETTING_NAME = "minimumReleaseAgeExclude" +_COMMENT_SEPARATOR = " #" + + +# Identical to the helper in ensure_pnpm_minimum_release_age_exclude.py on purpose: each copier task script is +# symlinked into generated repos on its own, so one importing another would break wherever only one lands, and +# this module is deleted at the end of the pnpm 12 migration regardless. +# pylint: disable=duplicate-code +def _parse_patterns(raw: str) -> list[str]: + patterns: list[str] = [] + for raw_pattern in raw.split(","): + # Quotes are removed before the emptiness check so a quoted-empty entry doesn't yield an empty pattern. + pattern = raw_pattern.strip().strip('"').strip("'") + if pattern == "": + continue + patterns.append(pattern) + return patterns + + +# pylint: enable=duplicate-code + + +def _split_trailing_comment(*, raw_value: str) -> tuple[str, str]: + separator_index = raw_value.find(_COMMENT_SEPARATOR) + if separator_index < 0: + return raw_value, "" + return raw_value[:separator_index], raw_value[separator_index:] + + +def _sequence_lines(*, patterns: list[str], comment: str) -> list[str]: + lines = [f"{_SETTING_NAME}:{comment}\n"] + for pattern in patterns: + # YAML single-quoted scalars escape an embedded quote by doubling it + escaped = pattern.replace("'", "''") + lines.append(f" - '{escaped}'\n") + return lines + + +def normalize_minimum_release_age_exclude(*, workspace_dir: Path) -> None: + """Rewrite a pre-pnpm-12 comma-delimited `minimumReleaseAgeExclude` string as a YAML sequence. + + pnpm 12 refuses to load a workspace manifest whose value for this setting is a scalar, and that + refusal fails every pnpm command rather than just an install, so the value cannot be repaired + with `pnpm config` once pnpm 12 is in place. Only the setting's own line is rewritten, leaving + the rest of the manifest -- including its comments -- byte for byte as it was. + """ + workspace_path = workspace_dir / _WORKSPACE_FILENAME + if not workspace_path.exists(): + print(f"{workspace_path} not found; skipping.") # noqa: T201 -- copier migration output must reach the user + return + + lines = workspace_path.read_text(encoding="utf-8").splitlines(keepends=True) + for index, line in enumerate(lines): + if not line.startswith(f"{_SETTING_NAME}:"): + continue + raw_value, comment = _split_trailing_comment(raw_value=line[len(_SETTING_NAME) + 1 :].strip()) + if raw_value.strip() == "": + print(f"{_SETTING_NAME} in {workspace_path} is already a list; nothing to do.") # noqa: T201 -- copier migration output must reach the user + return + patterns = _parse_patterns(raw_value) + if len(patterns) == 0: + # An empty string is not a valid list either, and the copier task rewrites the value anyway + del lines[index] + _ = workspace_path.write_text("".join(lines), encoding="utf-8") + print(f"Removed the empty {_SETTING_NAME} string from {workspace_path}.") # noqa: T201 -- copier migration output must reach the user + return + lines[index : index + 1] = _sequence_lines(patterns=patterns, comment=comment) + _ = workspace_path.write_text("".join(lines), encoding="utf-8") + print(f"Rewrote {_SETTING_NAME} in {workspace_path} as a YAML list of {len(patterns)} pattern(s).") # noqa: T201 -- copier migration output must reach the user + return + + print(f"{_SETTING_NAME} not set in {workspace_path}; nothing to do.") # noqa: T201 -- copier migration output must reach the user + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + _ = parser.add_argument("--target-dir", default=".", dest="target_dir") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + normalize_minimum_release_age_exclude(workspace_dir=Path(args.target_dir)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/template/.claude/settings/models.jsonc b/template/.claude/settings/models.jsonc index d8918e1..25546fe 100644 --- a/template/.claude/settings/models.jsonc +++ b/template/.claude/settings/models.jsonc @@ -9,6 +9,7 @@ */ { "availableModels": [ + "fable", "sonnet", "opus", "haiku", diff --git a/template/.claude/settings/permissions/write.jsonc b/template/.claude/settings/permissions/edit.jsonc similarity index 76% rename from template/.claude/settings/permissions/write.jsonc rename to template/.claude/settings/permissions/edit.jsonc index 4ce0d81..dc1600d 100644 --- a/template/.claude/settings/permissions/write.jsonc +++ b/template/.claude/settings/permissions/edit.jsonc @@ -8,12 +8,13 @@ * ===================================================================================================== */ { - // Write permissions for specific files + // Edit permissions for specific files // This should only ever be running in a devcontainer, so pretty lenient permissions are allowed "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "allow": ["Write(/tmp/**/*)", "Write(/workspaces/**/tmp/**)", "Write(tmp/**)"], + // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix + "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Write(Taskfile.yaml)", "Write(.config/taskfiles/**)", "Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], }, } diff --git a/template/.claude/skills/address-pr-comments/SKILL.md b/template/.claude/skills/address-pr-comments/SKILL.md index 44eeed8..4d90562 100644 --- a/template/.claude/skills/address-pr-comments/SKILL.md +++ b/template/.claude/skills/address-pr-comments/SKILL.md @@ -2,7 +2,7 @@ name: address-pr-comments description: Addresses PR review comments by making code changes and posting replies. Takes a PR number or auto-detects from current branch. Use when the user wants to address review feedback, respond to PR comments, fix PR feedback, or says "address comments on PR X". user-invocable: true -allowed-tools: Bash, Read, Write, Edit, Grep, Glob, AskUserQuestion +allowed-tools: Bash, Read, Write, Edit, Grep, Glob, AskUserQuestion, Skill adapted-from: https://github.com/josh-gree/my-claude-skills --- @@ -190,9 +190,12 @@ Within a single Phase 2 invocation, the order below is strict. The invariants ap > **Never commit before the reply body is finalized with the user. Never push before all reply texts in the batch are finalized. Never post a reply before its commit has been pushed. Never skip the commit link in a code-change reply.** 1. For each queued **code change** (in order): - - Follow the `/create-issues` process: create a bd issue with a proper title, description (why this change, context from the PR comment), design (technical approach), and Given-When-Then acceptance criteria. Export after creating. + - Invoke the `Skill` tool with `skill: "create-issues"`, passing the reviewer's comment and the agreed change as `args`. Follow the process it returns to create a bd issue with a proper title, description (why this change, context from the PR comment), design (technical approach), and Given-When-Then acceptance criteria. Export after creating. - Mark the issue in progress - - If the change involves code: execute the TDD red/green/refactor cycle (`/red` → `/green` → `/refactor`) against the acceptance criteria. If the change is non-code (docs, markdown, config, scripts, etc.): make the change directly — TDD does not apply, but the remaining steps are identical. + - Decide first whether the change is testable code. **If it is not** — documentation, markdown, comments, config, shell scripts where the project has no harness for them, or any other change with no behaviour to assert — make the change directly and move to the next step. Do **not** invoke `red`, `green` or `refactor`, and do not invent a test to justify the change: TDD does not apply here. Everything after this step is identical either way. + - **If it is testable code**, work through the TDD cycle by invoking the `Skill` tool once per phase, in this order — `skill: "red"`, then `skill: "green"`, then `skill: "refactor"` — passing the bd issue ID and the acceptance criterion being driven as `args`. Repeat the three-phase cycle until every acceptance criterion is satisfied. + + **Each phase must be a real `Skill` tool call — not a description of one, and not your own recollection of what red/green/refactor mean.** Those skills carry project-specific rules that general TDD practice does not include, and they are re-read fresh each cycle so later cycles do not drift from earlier ones. Writing a test or an implementation without a preceding `Skill` call for that phase is a workflow violation. - **Finalize the reply body with the user — before committing.** Draft the reply text (everything except the commit link) — **do NOT include the AI attribution footer; `check-footer.py` appends it**. Write to ``, tell the user the absolute path (per [Conventions](#conventions)), and go through the approve/edit loop with the user until they approve. Do not proceed until the user explicitly confirms the text. Leave a clear `[COMMIT LINK]` placeholder where the link will go. Use AskUserQuestion with this wording (do NOT say "Post now" — the reply is queued for posting after the push, not posted immediately): @@ -252,6 +255,8 @@ Summarise what was done: - Skip comments without asking - Leave uncommitted changes - Include the AI attribution footer in generated reply text — `check-footer.py` is solely responsible for it +- Write tests or implementation from your own knowledge of TDD — when a change is testable code, `red`, `green` and `refactor` must each be invoked through the `Skill` tool +- Run the TDD cycle on a change that has no behaviour to assert — docs, markdown, comments, config and unharnessed shell scripts are made directly ## Handling Common Scenarios @@ -273,7 +278,7 @@ Discuss with user, then post a reply explaining the reasoning if they want to pu - [ ] If `--resume`: build inventory, classify reply drafts (placeholder vs finalized vs orphan), prompt user for resume strategy - [ ] Fetch and display comments - [ ] Phase 1: collect decisions for all comments (reply-only comments posted in Phase 1) -- [ ] Phase 2: implement code changes, one commit per comment +- [ ] Phase 2: invoke `create-issues` via the `Skill` tool for every queued item; add `red` → `green` → `refactor` only for items that are testable code; one commit per comment - [ ] Push all commits in a single push - [ ] Post code-change replies with commit links after push - [ ] Report what was done diff --git a/template/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs b/template/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs new file mode 120000 index 0000000..b008812 --- /dev/null +++ b/template/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs @@ -0,0 +1 @@ +../../../copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs \ No newline at end of file diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index be728b5..b206431 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -34,15 +34,15 @@ "-AmazonWebServices.aws-toolkit-vscode", // the AWS CLI feature installs this automatically, but it's causing problems in VS Code{% endraw %}{% endif %}{% raw %} // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.5", + "coderabbit.coderabbit-vscode@0.21.7", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905",{% endraw %}{% if install_claude_cli %}{% raw %} - "anthropic.claude-code@2.1.252",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} + "anthropic.claude-code@2.1.273",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.2.9002", + "meta.pyrefly@1.3.1", "ms-vscode-remote.remote-containers@0.414.0", "charliermarsh.ruff@2026.68.0",{% endraw %}{% endif %}{% raw %} {% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %} diff --git a/template/.github/actions/install_deps/action.yml b/template/.github/actions/install_deps/action.yml index b91d8b3..a995b8f 100644 --- a/template/.github/actions/install_deps/action.yml +++ b/template/.github/actions/install_deps/action.yml @@ -16,8 +16,8 @@ inputs: description: Node version to install required: false # if this is set, then assume you want node dependencies installed default: 'notUsing' - package-json-file: - description: Relative path to a package.json to read the pnpm version from (its `packageManager` or `devEngines.packageManager` field). When unset, the pnpm version pinned by the template is used instead. + working-directory: + description: Relative path to the directory holding the package.json to read the pnpm version from (its `packageManager` or `devEngines.packageManager` field). When unset, the pnpm version pinned by the template is used instead. required: false default: 'notUsing' install-deps: @@ -68,43 +68,52 @@ runs: with: python-version: ${{ env.PYTHON_VERSION }} - - name: Check for a pnpm lock file # pnpm/setup can only cache the store when there's a lock file at the repo root to key the cache off of - id: pnpm-lock-probe + - name: Locate the pnpm project # pnpm/setup keys the store cache off a lock file, so it can only cache when one exists in the directory the project lives in + id: pnpm-project if: ${{ inputs.node-version != 'notUsing' }} + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | - if [ -f pnpm-lock.yaml ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" + if [ "$WORKING_DIRECTORY" = "notUsing" ]; then + project_dir="." else - echo "exists=false" >> "$GITHUB_OUTPUT" + project_dir="$WORKING_DIRECTORY" + fi + echo "dir=$project_dir" >> "$GITHUB_OUTPUT" + if [ -f "$project_dir/pnpm-lock.yaml" ]; then + echo "lock_exists=true" >> "$GITHUB_OUTPUT" + else + echo "lock_exists=false" >> "$GITHUB_OUTPUT" fi shell: bash - name: Setup pnpm and node # this action installs both pnpm and the node runtime, so actions/setup-node is not needed if: ${{ inputs.node-version != 'notUsing' }} - uses: pnpm/setup@v2.0.2 + uses: pnpm/setup@v2.1.0 with: - # an empty version makes pnpm/setup read it from package-json-file instead. Passing both a version and a package.json with a conflicting `packageManager` field is a hard error in pnpm/setup. - version: ${{ inputs.package-json-file == 'notUsing' && '11.22.0' || '' }} - package-json-file: ${{ inputs.package-json-file == 'notUsing' && 'package.json' || inputs.package-json-file }} + # an empty version makes pnpm/setup read it from the manifest in working-directory instead. Passing both a version and a package.json with a conflicting `packageManager` field is a hard error in pnpm/setup. + version: ${{ inputs.working-directory == 'notUsing' && '12.4.2' || '' }} + working-directory: ${{ steps.pnpm-project.outputs.dir }} + # cache-dependency-path is left at its default, which is the lock file inside working-directory -- the same one the probe above checks runtime: node@${{ inputs.node-version }} - cache: ${{ steps.pnpm-lock-probe.outputs.exists }} + cache: ${{ steps.pnpm-project.outputs.lock_exists }} install: false # dependency installation is handled by manual-setup-deps.py, which knows about every environment in the repo - name: Install tooling # the funky syntax is github action ternary # --no-node is always passed because the only node tooling this script installs is pnpm, which pnpm/setup above already installed - run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager && '--skip-installing-ssm-plugin' || '' }} + run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} shell: pwsh - name: OIDC Auth for CodeArtifact if: ${{ inputs.code-artifact-auth-role-name != 'no-code-artifact' }} - uses: aws-actions/configure-aws-credentials@v6.2.3 + uses: aws-actions/configure-aws-credentials@v6.3.0 with: role-to-assume: arn:aws:iam::${{ inputs.code-artifact-auth-role-account-id }}:role/${{ inputs.code-artifact-auth-role-name }} aws-region: ${{ inputs.code-artifact-auth-region }} - name: Install dependencies # the funky syntax is github action ternary - if: ${{ inputs.install-deps }} - run: python .devcontainer/manual-setup-deps.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} ${{ inputs.node-version == 'notUsing' && '--no-node' || '' }} ${{ inputs.skip-updating-devcontainer-hash && '--skip-updating-devcontainer-hash' || '' }} ${{ inputs.skip-installing-pulumi-cli && '--skip-installing-pulumi-cli' || '' }} + if: ${{ inputs.install-deps == 'true' }} + run: python .devcontainer/manual-setup-deps.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} ${{ inputs.node-version == 'notUsing' && '--no-node' || '' }} ${{ inputs.skip-updating-devcontainer-hash == 'true' && '--skip-updating-devcontainer-hash' || '' }} ${{ inputs.skip-installing-pulumi-cli == 'true' && '--skip-installing-pulumi-cli' || '' }} shell: pwsh diff --git a/template/.github/dependabot.yml.jinja b/template/.github/dependabot.yml.jinja index 996cbc0..93951da 100644 --- a/template/.github/dependabot.yml.jinja +++ b/template/.github/dependabot.yml.jinja @@ -20,6 +20,9 @@ updates: time: "16:00" open-pull-requests-limit: 5 ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-patch" # we don't want to be bothered with patch updates for anything except security updates - dependency-name: "boto3" # boto3 gets patch updates way too frequently and they're usually not important update-types: - "version-update:semver-patch" diff --git a/template/.github/workflows/ci.yaml.jinja b/template/.github/workflows/ci.yaml.jinja index 305bff4..097fdd4 100644 --- a/template/.github/workflows/ci.yaml.jinja +++ b/template/.github/workflows/ci.yaml.jinja @@ -138,16 +138,16 @@ jobs: if: always() steps: - name: fail if prior job failure + # Iterating the needs context keeps this gate from drifting out of sync with the job list above + env: + NEEDS: ${{ toJSON(needs) }} run: | - success_pattern="^(skipped|success)$" # these are the possibilities: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context - - if [[ ! "${{ needs.get-values.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.lint.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.test-unit.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.test-visual-regression.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.check-skip-duplicate.result }}" =~ $success_pattern ]] || - [[ ! "${{ needs.confirm-on-tagged-copier-template.result }}" =~ $success_pattern ]]; then - echo "❌ One or more jobs did not finish with skipped or success" + # these are the possibilities: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context + not_ok=$(jq -r 'to_entries[] | select(.value.result | test("^(skipped|success)$") | not) | .key' <<<"$NEEDS") + + if [[ -n "$not_ok" ]]; then + echo "❌ One or more jobs did not finish with skipped or success:" + echo "$not_ok" exit 1 fi echo "✅ All jobs finished with skipped or success" diff --git a/template/.pre-commit-config.yaml b/template/.pre-commit-config.yaml index 3f1f013..9b501c7 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.pre-commit-config.yaml @@ -122,6 +122,8 @@ repos: .*tsconfig.json| )$ - id: check-yaml + # some tools (e.g. pnpm) create multiple-document YAML streams + args: [--allow-multiple-documents] # .copier-answers.yml is an autogenerated YAML file by Copier that throws 'found unhashable key' errors # Helm charts contain other non-YAML markers that cause errors here. they should be checked with helm-lint instead # VCRpy cassettes sometimes contain non-YAML markers that cause errors---but they're autogenerated anyway diff --git a/template/eslint.shared.mjs b/template/eslint.shared.mjs index 6d798c5..faa3230 120000 --- a/template/eslint.shared.mjs +++ b/template/eslint.shared.mjs @@ -1 +1 @@ -../copier_template_resources/eslint.shared.mjs \ No newline at end of file +../copier_template_resources/eslint/eslint.shared.mjs \ No newline at end of file diff --git a/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py b/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py index e9c711f..dc44667 100644 --- a/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py +++ b/tests/unit/copier_tasks/test_ensure_pnpm_minimum_release_age_exclude.py @@ -50,13 +50,13 @@ def test_When_workspace_has_existing_patterns__Then_new_patterns_appended( existing = faker.name() new = faker.name() workspace = tmp_path / "pnpm-workspace.yaml" - _ = workspace.write_text(f"minimumReleaseAgeExclude: {existing}\n", encoding="utf-8") + _ = workspace.write_text(f"minimumReleaseAgeExclude:\n - {existing}\n", encoding="utf-8") result = self._run_script(patterns=new, target_dir=tmp_path) assert result.returncode == 0 parsed = yaml.safe_load(workspace.read_text(encoding="utf-8")) - assert parsed["minimumReleaseAgeExclude"] == f"{existing},{new}" + assert parsed["minimumReleaseAgeExclude"] == [existing, new] def test_When_patterns_include_a_quoted_empty_entry__Then_only_the_real_pattern_is_set( self, tmp_path: Path, faker: Faker @@ -70,7 +70,7 @@ def test_When_patterns_include_a_quoted_empty_entry__Then_only_the_real_pattern_ parsed = yaml.safe_load(workspace.read_text(encoding="utf-8")) assert result.returncode == 0 - assert parsed["minimumReleaseAgeExclude"] == plain + assert parsed["minimumReleaseAgeExclude"] == [plain] def test_When_patterns_provided__Then_sets_value_in_workspace(self, tmp_path: Path, faker: Faker) -> None: scoped = f"{faker.name()}/{faker.name()}" @@ -82,4 +82,4 @@ def test_When_patterns_provided__Then_sets_value_in_workspace(self, tmp_path: Pa assert result.returncode == 0 parsed = yaml.safe_load(workspace.read_text(encoding="utf-8")) - assert parsed["minimumReleaseAgeExclude"] == f"{scoped},{plain}" + assert parsed["minimumReleaseAgeExclude"] == [scoped, plain] diff --git a/uv.lock b/uv.lock index 09665c5..69da87c 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "copier" -version = "9.17.1" +version = "9.18.2" 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/40/65/11c8635a27ce06ef525946bb02b1d5271b0f11e5acf9c5ab6d242512598e/copier-9.17.1.tar.gz", hash = "sha256:778e85f1c2de86824a72ddf4a34140744921d6354450c427c4c8a475371b1bda", size = 647338, upload-time = "2026-08-04T09:15:04.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/60/2fe1777b27f489450ed8ac5c75390caf91a175d647be05b4284bf515b413/copier-9.18.2.tar.gz", hash = "sha256:0f97902fee79374551c9391fb1132f5a038079f3b497f4dad673295489fc032e", size = 669824, upload-time = "2026-09-07T13:14:38.435Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8d/2a/854d86eb28ced1c4ade3d8c83bdc7ba529ac5df6c80b5d5202a090244d7a/copier-9.18.2-py3-none-any.whl", hash = "sha256:ef0e1b09dece031b09085f2aa637bdc3d5952b98d96b986feaa45a8f3a00d2c6", size = 68113, upload-time = "2026-09-07T13:14:36.934Z" }, ] [[package]] @@ -72,13 +72,13 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "copier", specifier = "==9.17.1" }, + { name = "copier", specifier = "==9.18.2" }, { name = "copier-template-extensions", specifier = "==0.3.3" }, - { name = "faker", specifier = ">=40.37.0" }, - { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "faker", specifier = ">=40.39.0" }, + { name = "pyrefly", specifier = ">=1.3.1" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "pytest-randomly", specifier = ">=4.1.0" }, + { name = "pytest-randomly", specifier = ">=5.0.0" }, ] [[package]] @@ -156,14 +156,14 @@ wheels = [ [[package]] name = "faker" -version = "40.37.0" +version = "40.39.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/fb/35acc76128d5ee8983d940da871cc8eba876e7b0e9e6bd402ecd7104dcdc/faker-40.37.0.tar.gz", hash = "sha256:a92dff7f310e61fb544c61720e15edb2e7448bc33d15a321a99e9ab7b94abf54", size = 2025920, upload-time = "2026-08-21T16:33:16.261Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/62/fea935af7a312f073c99d470b05a7c96650ee8d9562ce82e8cb7ee4c1247/faker-40.39.0.tar.gz", hash = "sha256:52799ad96fcf92aab2fc96a9e03869bbf1a634300f7cd91c38dbfd51f639f6fa", size = 2029463, upload-time = "2026-09-14T16:50:17.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/22/bf589b6b2c7527047f55450358fbe5961aeaadf168d6b51a1a1c67da497f/faker-40.37.0-py3-none-any.whl", hash = "sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490", size = 2062853, upload-time = "2026-08-21T16:33:14.516Z" }, + { url = "https://files.pythonhosted.org/packages/45/c8/901282279841ebb59216433be01aac825fb905e1a597de3b7b5ab8b0c43e/faker-40.39.0-py3-none-any.whl", hash = "sha256:c4c7ec2cddfaf602c5a8a2c960614946aa8c161250bd9d49a8846cd24d1ef028", size = 2066232, upload-time = "2026-09-14T16:50:16.061Z" }, ] [[package]] @@ -400,21 +400,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.2.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, - { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, - { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, - { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, - { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, - { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, - { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, - { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" }, + { url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" }, + { url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" }, ] [[package]] @@ -449,14 +449,14 @@ wheels = [ [[package]] name = "pytest-randomly" -version = "4.1.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/3b/6a40e1b9d925651e601e056a97f60d8a1daeddeac03d5609be60cb4362ce/pytest_randomly-5.0.0.tar.gz", hash = "sha256:e9c575a5873ef168ddbe340ed9e97ce9edb4492ccc821e4b2ac6bb1f0ed515d2", size = 8542, upload-time = "2026-09-01T22:34:20.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/10/b4/47e939285caad9a623d021512912ac08dc92a467ad075d179f43729d2934/pytest_randomly-5.0.0-py3-none-any.whl", hash = "sha256:8a0d4703115c0c25b38b6e129fc16b1947b9643ff26a41bc1d185d7e5a7689c1", size = 8920, upload-time = "2026-09-01T22:34:19.227Z" }, ] [[package]]