Shared developer tooling for BMS projects: PR build/check status and a
one-shot PR summary (bdt pr info), PR creation, claiming an issue (bdt issue take), issue/work item creation and comments, git worktrees (creation and
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
pre-flight checks, Azure log queries, and static checks (bdt lint) for
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
TypeScript and baseline tooling, plus bdt dead-code for .sql files nothing loads and
backend routes nothing calls.
bdt pr * and bdt issue * auto-detect whether the current repo's origin
remote is Azure DevOps or GitHub and use az/gh accordingly.
Consolidates near-duplicate scripts that used to be copy-pasted across
OneSales, ccmt2, and MDMApp into one versioned package with a bdt CLI.
Requires git always, plus az (Azure DevOps commands, and all bdt logs
commands) and/or gh (GitHub commands) on PATH as needed — each is checked
lazily, only when a command actually needs it, with a clear error and an
install link if missing rather than a raw traceback. Works on Windows: CLI
shims (e.g. az.cmd) are resolved via shutil.which (which honors
PATHEXT) rather than shelling out, output is decoded as UTF-8 rather than
relying on the console's default codepage, and file arguments accept either
slash style.
uv tool install bmsdna-devtoolsOr as a project dependency: uv add bmsdna-devtools.
Find the PR opened from the current branch and report build/check status
(failed steps print their logs inline). Works against Azure DevOps or
GitHub — whichever origin points at.
bdt pr status [--target-branch BRANCH] [--wait]The --target / --target-branch option of pr create/retry/publish/update/comment/watch-deploy and commit
defaults to dev; pass --target main (or whatever your PRs go into) to override. pr status, pr info and
issue take only look a PR up, so by default they don't filter on the target branch at all.
If the PR can't be merged, that's reported immediately instead of polling
for builds/checks that will never run — e.g. on Azure DevOps:
PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts);
on GitHub: PR #42 ('feat: widgets') has merge conflicts with 'main' (mergeable=CONFLICTING).
Exit code 1 either way.
With --wait, a build/check that's paused on a manual approval (an Azure
Pipelines stage's Checkpoint.Approval, or a GitHub Actions deployment
protection rule) ends the wait instead of polling forever — it prints which
stage/check needs a reviewer plus a link to act on it, and exits 3
(bdt pr watch-deploy --wait, below, behaves the same way). If another
build/check has already failed, that's reported instead (exit 1) even when
one is also waiting on approval.
Azure DevOps: org/project/repo are auto-detected from
git remote get-url origin (handles SSH, dev.azure.com HTTPS, and
*.visualstudio.com HTTPS forms). Auth is an explicit PAT (--pat or
AZURE_DEVOPS_EXT_PAT/AZURE_DEVOPS_PAT env var), falling back to a short-lived token from the
caller's own az login — never embed a PAT literal in a script or CI file.
--target-branch optionally restricts the lookup to PRs into that branch (default: any).
GitHub: uses gh's own auth (gh auth login) and always resolves the
PR opened from the current branch — gh pr view has no target-branch
filter, so --target-branch is ignored here; the PR's actual base branch
is shown in the output. Check status is computed from
gh pr view --json statusCheckRollup rather than gh pr checks --json,
since the latter flag isn't available in all gh releases.
Pass --pr-id <number> to check a specific PR directly instead of resolving
it from the current branch — useful when that PR's branch isn't checked out
locally at all. bdt pr retry, pr publish, pr update, and pr comment
all accept the same option, for the same reason. (pr watch-deploy doesn't:
it watches a branch-triggered build/workflow run, not any particular PR.)
bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
build state (passing, failing, pending, waiting for a manual approval, none) and the
issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. --json is what
a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way bdt pr status does: on GitHub from
the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
waiting when a stage needs approval). unknown if Azure DevOps can't be asked.
Azure DevOps limits: the PR is found by the current branch (into --target-branch if given, else any target),
a PR with merge conflicts is reported as an error (exit 1) like pr status does, and the closed
work items are only those whose URL is in the PR description -- not ones linked via
bdt pr create --issue.
Retry only the failed job(s)/stage(s) of the most recent build/run for the
PR opened from the current branch — not a whole new build/run. Whenever bdt pr status reports a failure, it prints a hint to run this.
bdt pr retry [--target-branch dev]No build/run ID needed — like bdt pr status, it resolves the PR (and its
latest build/run per pipeline/workflow) from the current branch, or from
--pr-id directly if given.
Azure DevOps: uses the retry=true query parameter on the "Update
Build" REST API
(PATCH .../_apis/build/builds/{buildId}?retry=true&api-version=7.1), which
reschedules whichever stage(s)/job(s) failed on the previous attempt (plus
anything depending on them) in place — distinct from queuing a brand new
build. One retry call per pipeline that has a failed build for the PR.
--target-branch selects which PR to look at, same as bdt pr status.
GitHub: uses gh run rerun <run-id> --failed, which reruns only the
failed job(s) (and their dependents) of a workflow run — the run ID(s) are
found automatically from the PR's failing checks. Checks that aren't backed
by a GitHub Actions run (e.g. a legacy commit status from an external CI)
can't be retried this way and are skipped; if none of the failing checks are
retryable, the command exits with an error.
Many pipelines have a second build/stage that only runs on the target
branch once a PR merges into it, and that's the one that actually deploys.
bdt pr status only watches builds/checks tied to the PR itself (its
merge/source ref or GitHub's statusCheckRollup), so it never sees that
second build. bdt pr watch-deploy does: it finds the most recent build/
workflow run triggered directly on --target-branch and reports its
status the same way pr status does (failed steps print their logs inline).
bdt pr watch-deploy [--target-branch dev] [--wait]After bdt pr status reports the PR's build/checks succeeded, if a build/
run has already been triggered on the target branch (e.g. the merge already
kicked one off), it prints a hint suggesting this command. That check is
best-effort — a failure reading it (auth, permissions, network) is silently
skipped rather than blocking or crashing pr status.
Azure DevOps: looks up builds via the same _apis/build/builds endpoint
pr status uses, just queried by branchName=refs/heads/<target-branch>
instead of the PR's merge/source ref.
GitHub: uses gh run list --branch <target-branch> --event push, which
selects workflow runs triggered by a push to that branch — as opposed to a
pull_request-triggered run for some still-open PR targeting the same
branch. Failed steps are printed via gh run view <id> --log-failed.
bdt pr create --target dev # or --target main / testCreates a PR from the current branch into --target. On Azure DevOps,
a thin wrapper around az repos pr create (org/project/repo inferred by
az itself from the git remote). On GitHub, gh pr create --fill (autofills
title/body from commit info so it never blocks on an interactive prompt).
Extra arguments pass through either way, e.g.
bdt pr create --target dev -- --title "...".
PRs are created as drafts by default; a successful create always prints
the PR's link plus bdt pr publish (which abstracts over the host) to mark
it ready for review. Pass --no-draft to open it ready for review
immediately instead.
--label (repeatable) applies labels to the PR on either host: on GitHub
these map to gh pr create --label, so the label must already exist on the
repo (gh label create); on Azure DevOps they map to az repos pr create --labels, which are freeform and get created on the fly. A successful
create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
az output otherwise gives you).
[tool.bdt.pr.required_labels] in pyproject.toml can require at least one
label from each named group before the PR is created — checked locally
(no gh/az call happens if a group isn't satisfied):
[tool.bdt.pr.required_labels]
type = ["bug", "feature", "chore"]
risk = ["breaking", "non-breaking"]With the above, bdt pr create --label feature --label breaking passes,
but bdt pr create --label feature fails with a message naming the unmet
group (risk) and its allowed choices.
[tool.bdt.pr.scope_labels] in pyproject.toml can auto-apply a label based on
the "scope" of the HEAD commit's conventional-commit subject (the
type(scope): description format bdt commit itself expects, e.g.
feat(customers): ...):
[tool.bdt.pr.scope_labels]
customers = "e2e-customers"
billing = "e2e-billing"With the above, creating a PR whose HEAD commit is feat(customers): add widget automatically adds the e2e-customers label (merged with, not
replacing, any explicit --label). No match, no [tool.bdt.pr.scope_labels]
table, or a commit subject without a (scope) all mean no label gets added —
opt-in, so repos that don't configure it see no change in behavior.
If --target has a build policy configured (an Azure DevOps Build policy,
or a GitHub branch protection rule requiring status checks), a successful
create prints a reminder to run bdt pr status afterward to check whether
the CI build passes. This is a best-effort check — failures reading policy
config (auth, permissions) fail open and simply skip the reminder.
bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
bdt issue update 1234 --state Resolved --tag fixed
bdt issue delete 1234 --yes
bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
bdt issue comment update 1234 5678 --message "Actually, see the second screenshot"
bdt issue comment delete 1234 5678 --yesCreates/updates/deletes an issue (GitHub) or work item (Azure DevOps), and
adds/edits/deletes comments on one, auto-detected from origin like bdt pr *. bdt issue comment add prints the new comment's ID so you can pass it to
update/delete later.
Destructive commands (issue delete, issue comment delete) require an
explicit --yes — there's no interactive confirmation prompt, since bdt is
also invoked by AI-agent callers that can't answer one.
--tag and --label are aliases of each other (repeatable on create,
search, and update) — pass whichever reads naturally; the values are
merged and routed to tags on Azure DevOps or labels on GitHub, whichever
backend is actually active, instead of being silently ignored by the other
one. Same for update's --remove-tag/--remove-label.
Azure DevOps: --type selects the work item type on create (Bug,
Task, User Story, ... — whatever the project's process defines; default
Bug). --tag/--label set/replace the full tag list (repeatable; omit
both on update to leave tags unchanged). update's --remove-tag/
--remove-label instead removes just the named tag(s) and leaves the rest —
unlike --tag/--label, which replace the whole set — by reading the work
item's current tags first (there's no Azure DevOps "remove one tag" patch
op). --state (update only) sets
System.State, e.g. Active, Resolved, Closed. --screenshot uploads
each image as a work item attachment (visible in the Attachments tab) and
posts a comment embedding them inline with Markdown — the Description field
defaults to HTML via the REST API, where a raw ![]() would just show as
literal text, but the work item Discussion/Comments control has always
rendered Markdown. issue delete soft-deletes to the project's Recycle Bin
(restorable, not permanent).
--board <team> sets the work item's Area Path to that Azure Boards team's
default, so it shows up on that team's board — a CLI flag beats
[tool.bdt.ado].board in pyproject.toml, which beats filing under the
project's root area:
[tool.bdt.ado]
board = "My Team"On update, --board only moves the item when you pass it explicitly — it
never falls back to pyproject.toml, so an unrelated field update (e.g.
just --title) can't silently relocate the item to a different board.
GitHub: a thin wrapper around gh issue create / edit / delete /
comment. --label/--tag add a label on create, or add/remove one on
update (paired with --remove-label/--remove-tag); labels must already
exist in the repo. --screenshot pushes images to a pr-assets branch (same trick bdt pr create --screenshot uses, since GitHub has no API for uploading an image
into an issue) and appends them to the issue body / comment as Markdown.
issue delete is permanent — GitHub has no recycle bin for issues.
Comment update/delete go through gh api directly (gh issue has no
subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
to bdt issue create pass through to gh issue create, e.g.
bdt issue create --title "..." -- --assignee @me.
--board <name> on create adds the issue to that GitHub Projects (v2)
board by title (needs a token with the project scope — gh auth refresh -s project). On search, --board (title or number) scopes results to issues
currently on that board; since gh issue list has no board filter of its
own, this fetches a wider raw pool and filters it down client-side, so a
board with few matches may return fewer results than --limit asks for. A
CLI flag beats [tool.bdt.github].board in pyproject.toml:
[tool.bdt.github]
board = "Roadmap"bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...]Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and
description and runs claude -p "<prompt>" --name "60: <title>", so the session is easy to find
with claude --resume. --agent picks another executable (it then gets just the prompt as its
last argument); extra args are passed through; --dry-run prints the command.
It takes the issue first (like bdt issue take): before the agent starts it comments Taken by <you>
on the issue, naming the session it is about to start -- for claude a --session-id is generated and
passed, so the comment says claude --resume <id>; the prompt tells the agent not to take it again. For
another --agent the comment just says (via <agent>). --dry-run posts nothing. Like issue take, it doesn't comment again when the newest comment already is your claim; if it is someone else's, it stops (exit 1) unless --force.
bdt issue take [NUMBER] [--target-branch BRANCH] [--force]Claims an issue (GitHub) or work item (Azure DevOps): comments Taken by <you> on it, followed by the
running coding agent's session (the same <Agent> Session: <id> note every bdt comment gets: a
claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
agent), so others can see who is on it. <you> is the GitHub login
gh is authenticated as, else the git user.name. Without NUMBER it takes the issue the current
branch's PR closes (Fixes #N, or an issue/work-item URL in the PR body); it refuses to guess if
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
says so and posts nothing, so it's safe to run repeatedly -- including before bdt issue do.
One translations.toml is the source of truth for all UI translations; bdt translate generates the
per-language de.json / en.json / ... files from it. The generated files are build artifacts --
add them to .gitignore and run bdt translate in your just install / build / CI steps.
# translations.toml (keys with dots must be quoted when `nested = true`)
[ADD_BUTTON]
en = "Add"
de = "Hinzufügen"
fr = "Ajouter"
it = "Aggiungi"
server_only = true # optional: never emitted into the JSON files (backend-only text)# pyproject.toml
[tool.bdt.translate]
file = "translations.toml" # default
languages = ["en", "de", "fr", "it"] # default
required_languages = ["en"] # default; `bdt translate add` fails without these
output = ["frontend/src/assets/i18n"] # directories receiving <lng>.json
nested = false # true: "a.b.c" keys become nested JSON objects
scan = ["frontend/src"] # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html- A missing language falls back to
de, thenen, then the key itself. - Keys used in code but absent from
translations.tomlare appended with an English placeholder and the command exits 1 without generating anything -- fill in the translations and rerun. bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungiadds a key totranslations.tomland regenerates all JSON files in one go (therequired_languagesare mandatory;--forceoverwrites an existing key).bdt translate --checkwrites nothing and exits 1 if code uses keys missing from the toml (for CI).bdt translate --importmerges existing<lng>.jsonfiles intotranslations.tomlonce, to migrate a repo that so far hand-maintained its JSON files; thengit rm --cachedthem and ignore them.
bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]Creates .worktrees/<name> branched from --base, initializes submodules
(unless --no-submodules), and copies an env file into the new worktree as
.env (auto-detects .local_env then .env if --env-file isn't given).
bdt find-repo my-repo [--root ~/projects] [--org MYORG] [--github-org MYORG] [--yes] [--pat PAT]Finds a repo by name: first among the local clones under the work dir, then -- only if there is
no local match -- in an Azure DevOps org and/or a GitHub org. Names match exactly
(case-insensitive) if any repo has that name, otherwise by substring. Local matches are printed
as paths. A single remote-only match is offered for cloning (--yes skips the prompt; without a
TTY it never prompts and just tells you to pass --yes); several matches are listed and nothing
is cloned.
Clone destinations: <root>/<project>/<repo> for Azure DevOps, <root>/github/<repo> for GitHub.
Configuration (flags override env vars):
| Setting | Flag | Env vars | Default |
|---|---|---|---|
| Local work dir | --root |
AZDO_WORK_DIR, BMS_WORK_DIR |
~/projects (C:/Projects on Windows) |
| Azure DevOps org | --org |
AZDO_ORG, BMS_ORG |
none (ADO not searched) |
| GitHub org | --github-org |
GITHUB_ORG, BMS_GITHUB_ORG |
none (GitHub not searched) |
| ADO PAT for cloning | --pat |
AZURE_DEVOPS_EXT_PAT, AZURE_DEVOPS_PAT |
falls back to az login |
Set the org env vars in your shell profile to make bdt find-repo <name> work anywhere. It replaces
the cross-repo-discovery skill's ALL_REPOS.md sync. Needs az (ADO) / gh (GitHub) only when
those orgs are searched.
bdt cleanup worktrees [root] [--remote origin] [--keep-dbs] [--yes]
bdt cleanup orphaned-dbs [root] [--include-caution] [--yes]
bdt cleanup db [path] [--confirm]
bdt cleanup worktree [path] [--keep-db] [--confirm]The first two recursively scan every git repo under root (default: .)
for worktrees. bdt cleanup worktrees prunes the ones fully merged into
<remote>/main/<remote>/test (falling back to local main/test if no
such remote refs exist) — e.g. a tree of .worktrees/<branch> directories
accumulated across several repos over time. bdt cleanup orphaned-dbs has no
--remote/merge-status notion at all: it just finds pgdevkit test DBs with
no matching live git worktree, regardless of whether that worktree was
ever merged anywhere. Like bdt issue delete, neither command has an
interactive prompt — both only ever print what they would remove/drop;
pass --yes to actually do it.
bdt cleanup db / bdt cleanup worktree instead target one specific,
still-live worktree — path defaults to ., so both are meant to be run
from inside the worktree in question, regardless of its merge status.
cleanup db only drops that worktree's own pgdevkit test DB(s), leaving the
worktree itself alone; cleanup worktree removes the worktree too (and,
unless --keep-db, its DB(s) along with it) — refusing the main checkout, a
protected branch (main/test), a locked worktree, or a dirty one
(submodules included). Since these two act on a single worktree a human
picked out by hand, rather than scanning for candidates, they default to an
interactive y/N confirmation instead of --yes; pass --confirm to skip
it for non-interactive use.
Every one of these four commands, before dropping any database, additionally
requires typing yes at an interactive prompt whenever --pg-host isn't
localhost/127.0.0.1/::1 — this specific check has no flag to bypass it
(not even --yes/--confirm), so a script or agent can never drop a
database on a shared/remote Postgres instance without a human confirming it
directly.
The DB-naming algorithm and orphan detection are entirely
pgdevkit's own
(pgdevkit.testdb.workspace_db_names() /
pgdevkit.testdb.find_orphaned_dbs()) — this used to be a hand-rolled
reimplementation here (to avoid an import), which risked drifting out of
sync with pgdevkit's actual naming; now that pgdevkit exposes both directly,
bdt just calls them. If a repo's root pyproject.toml has a
[tool.pgdevkit].engine = "postgres" (the default once [tool.pgdevkit]
exists at all), removing one of its worktrees also drops the Postgres test
database(s) pgdevkit created for that branch — pass --keep-dbs to skip
that. A DB whose name ends in a bare branch name
(main/test/dev/head/i18n, rather than a slugified feature branch)
is flagged ⚠ possibly a standing reference DB and excluded even with
--yes, since that might be an intentional baseline DB rather than an
orphaned leftover — pass --include-caution too if you've verified it
really is safe to drop. That flagging (bdt's own heuristic, not pgdevkit's)
is the one bit of naming-adjacent logic still here.
A repo can additionally own sibling test DBs (e.g. a second DB for a
vendored mock service) and nested ones (an unrelated per-branch DB, under
a different pgdevkit project name, that happens to share the same branch).
Siblings are pgdevkit's own concern now — configure
[tool.pgdevkit].extra_db_suffixes in the consuming repo (see pgdevkit's
README) and both ensure_testdb-side tooling and bdt cleanup pick it up
automatically. Nested projects have no pgdevkit equivalent (it's a wholly
separate project name/pyproject.toml, not a literal suffix of the same
project's DB), so that stays configured here:
[tool.bdt.worktree]
db_nested_projects = ["akeneo_editor"] # a wholly separate per-branch DB,
# its own (possibly section-less)
# pyproject.toml, sharing this
# worktree's branch--pg-port/--pg-user (all four commands; env vars PGPORT/PGUSER, no
fallback to $USER/$LOGNAME — those are set in virtually every shell,
which would make the pgdevkit-user default below never fire) default to
pgdevkit's own test-container port/user, not the OS user or Postgres'
standard 5432 — bdt cleanup orphaned-dbs's listing step always connects
via pgdevkit's own
PGDEVKIT_TESTDB_*-driven resolution (it's calling straight into pgdevkit),
so its own psql-based DROP step defaults to matching that, rather than
silently targeting a different Postgres instance than the one that was just
queried. Pass --pg-port/--pg-user explicitly if your setup deliberately
differs.
bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify] [--subrepo database]Stages, commits, and pushes the given files. Pre-flight checks: files
exist, commit message follows Conventional Commits
(type(scope): description, skip with --skip-message-check), not on
main/master (skip with --allow-main). Retries once (re-git add) if a
pre-commit hook reformats files. Pass --subrepo <dir> (repeatable) for
repos that vendor a submodule (e.g. database) — files under that prefix
are committed/pushed inside the submodule first, then the bump is staged in
the parent repo.
The built-in commit types are feat, fix, docs, style, refactor,
perf, test, build, ci, chore, revert. A repo can accept
additional types on top of those under [tool.bdt.commit] in
pyproject.toml:
[tool.bdt.commit]
types = ["sql", "infra"]Scope (the (x) in feat(x): ...) is unrestricted by default — any scope,
or none at all, is accepted. A repo can opt into restricting it to a fixed
list under the same table:
[tool.bdt.commit]
scopes = ["api", "ui", "db"]Once configured, a message that names a scope must use one from the list — but a message with no scope at all is still always accepted; this doesn't make a scope mandatory.
If the pushed commit's type is feat and the current branch's PR is
already published (not a draft), it's converted back to draft — a feature
needs a fresh review pass before CI/merge, not just whatever review
happened before the commit existed. Run bdt pr publish when it's ready
again. Pass --target/--pat to resolve the PR on Azure DevOps (GitHub
always resolves the current branch's PR directly).
Set IS_BMS_AI_SANDBOX=1 to skip the push step (commit only) — used when
an AI coding sandbox pushes on its own schedule separately. The draft
conversion above only runs after an actual push, so it's skipped in
sandbox mode too.
--json emits a machine-readable result for AI-agent callers:
{
"success": true, "committed": true, "pushed": true,
"message": "...", "files": ["..."], "commit_sha": "abc1234",
"error": null, "hint": null, "commit_type": "feat", "commit_scope": "x"
}Query Application Insights (KQL over traces/exceptions) via
az monitor app-insights query. No defaults are baked in — pass
--resource-group/--app-insights explicitly (or set
AZURE_RESOURCE_GROUP/AZURE_APP_INSIGHTS), since which Azure resource
"this repo" maps to isn't derivable from the git remote.
bdt logs roles --resource-group my-rg --app-insights my-app-insights --minutes 60
bdt logs tail --resource-group my-rg --app-insights my-app-insights --role my-service --level warningDownloads the App Service log archive for a webapp/slot via
az webapp log download, unzips it, and writes every line matching a
common error/warning marker (ERROR, CRITICAL, WARNING, tracebacks,
4xx/5xx, FAILED, FATAL) to <out>/<slot>_errors.log. Simpler and
often preferable to the KQL commands above when you just want "what broke
recently" rather than a queryable trace stream.
The webapp/resource-group/slot come from a named environment configured in
the calling repo's pyproject.toml. slot is optional — omit it for an
app's default/production slot (no --slot is passed to az); set it for a
named deployment slot:
[tool.bdt.envs.prod]
webapp = "my-webapp"
resource_group = "my-rg"
[tool.bdt.envs.test]
webapp = "my-webapp"
resource_group = "my-rg"
slot = "test"bdt logs fetch --env prod
bdt logs fetch --env prod --out logs/ --keep-archiveStatic checks (implementing bmsuisse/skills#52)
for the postgres-best-practices skill's SQL rules, pydantic-model placement, hand-wired HTTP in
TypeScript, and that the repo has its baseline tooling actually set up. Unused .sql files and
dead backend routes are a separate command, bdt dead-code:
bdt lint # scan the current directory, recursively
bdt lint backend/ # scan one directory
bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-commit
# hook's staged-file list, so it can run on the diff only
bdt lint --no-tooling-check # skip the tooling-config check for this runEvery .execute()/.executemany() call whose SQL argument can be resolved to a
literal or f-string/concatenation/%-format expression is checked (an
unresolvable argument, e.g. a plain function parameter, is silently skipped --
this can't false-positive on non-psycopg .execute() calls, or on dynamic SQL
it can't see through):
sql-inline-too-complex— more than a trivial (≤4 line) query, or a JOIN/CTE/subquery/aggregation, inline instead ofload_sql()/a.sqlfile. SimpleINSERT/UPDATE/DELETEare exempt from the line limit;INSERT ... SELECT,UPDATE ... FROMandDELETE ... USINGstill count as complex. Also applied to any*_sql/*_SQLvariable assigned a literal, even if it never reaches an.execute()call in the same file (e.g. it's handed to a helper).sql-fstring-injection/sql-concat-injection/sql-percent-format-injection/sql-format-injection— SQL built with an f-string,+concatenation, the%operator, orstr.format()instead of a psycopg t-string (3.14+),psycopg.sql, or bound params. For an f-string the fix is usually justf"..."->t"..."({value}is bound,{name:i}quotes an identifier).sql-positional-param— positional%sinstead of named%(name)s.sql-forbidden-join—RIGHT JOIN/LATERAL JOIN/CROSS APPLY(same patterns theprekskill'scheck_files.pyforbids in.sqlfiles).
A candidate is only ever flagged once its (resolved) text actually parses with
sqlglot as a SELECT/INSERT/UPDATE/
DELETE/UNION/MERGE — this is what lets bdt lint scan any .execute()
call, regardless of driver, without flagging e.g. a duckdb COPY ... TO export.
pydantic-model-misplaced — a pydantic model (or PostgresTableModel)
with more than 5 fields defined directly under an api/ directory, instead of
an api/models/(/schemas//dto/) module.
ts-handwired-http / ts-handwired-model (TypeScript, .ts/.tsx/.mts,
bmsuisse/devtools#52) — a frontend
package that already generates an API client from the backend's OpenAPI schema
(its nearest package.json depends on openapi-typescript/openapi-fetch,
@hey-api/openapi-ts, orval, ... or has an openapi script) should use it. The
rule flags hand-wired fetch(...), axios/axios.create(...) and
new XMLHttpRequest() (ts-handwired-http), and a .json() result typed by
casting/annotating a hand-written model (ts-handwired-model, only reported
outside the block of a fetch/axios call, which the http rule already covers). Packages without a generator are skipped, as
are generated code (generated/, *.gen.ts, *.generated.ts, api-types.ts,
*.d.ts; also their .mts/.tsx variants), tests (*.test.*, *.spec.*, __tests__/, e2e/, tests/) and
third-party URLs (fetch("https://...")). Hand-wired access is fine for files and
other non-JSON traffic, since generators handle those badly, so a call is not
flagged when its enclosing block mentions FormData, Blob/.blob()/
.arrayBuffer(), getReader()/response.body/TextDecoder/EventSource/SSE,
createObjectURL, new File(, an application/octet-stream/multipart//
text/event-stream content type, or returns .text() (a plain-text response), or sits in a createClient(...) setup (a fetch passed to the generated client).
It's a tokenizer-level heuristic, not a type-aware analysis; to silence a
legitimate exception, put a comment on (or right above) the line:
// bdt-lint: ignore ts-handwired-http -- websocket handshake, not in the schemaTooling config — the repo must declare ty, ruff and pytest as
dependencies, have pytest configured ([tool.pytest.ini_options] or a
pytest.ini/setup.cfg), and have a prek.toml (see the prek skill).
Never a hard block — bypass it for one run with --no-tooling-check, or
permanently for the repo via pyproject.toml:
[tool.bdt.lint]
skip_tooling_check = trueOther [tool.bdt.lint] knobs (all optional): exclude_dirs (extra directory
names to skip, beyond the built-in .venv/node_modules/etc. list),
pydantic_field_threshold (default 5), pydantic_base_classes (default
["BaseModel", "PostgresTableModel"]), pydantic_allowed_subdirs (default
["models", "schemas", "dto"]), pydantic_api_dir_names (default ["api"]),
ts_exclude_globs (repo-relative globs of TypeScript files to skip, e.g.
["src/legacy/*"]), ts_non_json_markers (extra strings that mark a call's
enclosing block as non-JSON traffic).
Requires sqlglot for the SQL checks —
already pulled in transitively via pgdevkit[db], but declared explicitly as
the bmsdna-devtools[lint] extra; a clear install hint is printed (not a raw
ImportError) if it's ever missing.
Dead code a linter can't see, in one command and one config table, [tool.bdt.dead_code]:
sql--.sqlfiles that no Python code loads;routes-- backend (FastAPI) routes that neither non-generated frontend code nor aurl_for(...)call uses.
Each check runs when it is configured (sql_roots, resp. [[tool.bdt.dead_code.apps]]); --only sql /
--only routes (repeatable) narrows a run, e.g. to keep the slow one -- routes imports the app, so run
bdt with the repo's own interpreter (uv run bdt dead-code) -- out of a prek hook. Exit code is 0 (clean), 1
(findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
within 5 minutes -- the error shows the import's output).
A .sql file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
folders that hold loadable SQL (not schema/migration scripts, which are applied rather than loaded) and every
file under them that nothing references is reported (sql-file-unreferenced):
[tool.bdt.dead_code]
sql_roots = ["backend/db/queries"] # repo-relative; a typo'd root is itself reported
sql_loader_functions = ["load_sql"] # default; add your own loader's name if it differs
sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"] # globs for files reached some other way
# exclude_dirs = ["generated"] # extra directory names to skip, beyond .venv/node_modules/etc.A file counts as referenced by a literal load_sql("topic", "name") call (positional or topic=/name=;
topic = the file's parent directory, name = its stem); by a load_sql("topic", some_var) call in a Python file that
also contains the stem as a string literal (so name = "a" if x else "b" and lookup tables work);
by a string literal that is a path whose trailing segments equal the file's repo-relative path
(get_sql_with_prm_list("backend/api/sql/x.sql")); or by its bare filename as a literal in a
Python file under the SQL folder's parent (_SQL_DIR / "x.sql"). It never executes code, so a
file reached through a fully computed path is a false positive -- list it in
sql_unreferenced_ignore. References are searched across the whole repo, since the caller can live anywhere.
A Python file that can't be parsed (including syntax newer than the interpreter running bdt) is itself
reported as sql-check-python-unparseable, because references in it are unknown.
Backend (FastAPI) operations that no non-generated frontend code calls and no backend code references by name -- dead routes that still have to be maintained, secured and tested.
[[tool.bdt.dead_code.apps]]
name = "akeneo" # label used in the report
app = "main_app:app" # module:attribute, imported in a subprocess ...
app_dir = "akeneo_editor/backend" # ... with this directory (repo-relative) as cwd/import root
# openapi = "frontend/openapi.json" # alternative to `app`: a committed OpenAPI document
frontends = ["akeneo_editor/frontend/src"] # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
exclude_prefixes = ["/external_api"] # routes meant for other callers (external API, webhooks)
exclude_tags = ["agent"] # e.g. LLM/MCP tools
exclude_paths = ["/auth/*", "GET /health"] # fnmatch globs on "/path" or "METHOD /path"
baseline = "dead-code-baseline.txt" # optional ratchet, see below
# env = { SOME_REQUIRED_SETTING = "x" } # extra environment for importing the app
# exclude_frontend_globs = ["src/legacy/*"] # repo-relative frontend files to ignore as callers
# url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a routePair each backend app with its own frontends (one [[...apps]] entry per backend) -- a route
called only by another backend's frontend is still unused here.
The backend inventory is app.openapi() of the app and of every mounted sub-app (with the
mount prefix), so it needs no committed schema and can't go stale; routes with
include_in_schema=False are not considered.
Generated code never counts as a caller. A generated client lists every route, so it is
skipped (generated/, *.gen.*, *.generated.*, api-types*, openapi_schema*, *.d.ts), as are
tests and e2e specs (*.test.*, *.spec.*, __tests__/, tests/, e2e/) -- for TypeScript,
JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
non-generated code of one of the app's frontends has (strongest first):
- sdk -- a reference to a hey-api SDK function (read from the
sdk.gen.tsunder that same frontend directory, matched on method and url) or one of its react-query helpers (fooOptions,fooMutation,fooQueryKey, ...). Eachfrontendsdirectory is scoped to its own SDK, so two apps that both generatelistItemsaren't confused; - fetch -- an openapi-fetch call
.GET("/path"naming this method and path (aDELETEof the same path with no call of its own is still reported); - url -- a string/template literal equal to the path template (any method), e.g. a hand-written
fetch(/api/x/${id}),"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/api/x/" + idor an<a href=...>-- nested templates like${qs ??a=${b}: ""}are handled; - url-sfx -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
or a
${base}prefix).
Routes the backend refers to by name are used, too. Auth redirects and OAuth callbacks have no frontend caller
by nature -- the backend builds their URL: request.url_for("auth_callback"), app.url_path_for("login"),
{{ url_for('login') }} in a template. A string literal passed (first argument, or name=) to one of
url_for_functions in a non-test Python file or Jinja-style template (.html, .jinja, .jinja2, .j2) under the app's app_dir
marks the route of that name as used ("mount:name" for a mounted sub-app counts as name). The route name is recovered from
its operationId: FastAPI's default name + path + method form, a bare name, or <prefix>-<name> from a custom
generate_unique_id; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
in a comment counts too.
It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
SPA <Link to="/users/${id}">), and URLs assembled from non-literal pieces (including a non-literal route
name in url_for) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
that exposes no documented operations (e.g. one wrapped in middleware that hides openapi()) is a setup error,
not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
catch-all, service-to-service endpoints and an external API folder.
Adopting it without fixing everything first: set baseline, run
uv run bdt dead-code --update-baseline once (it concerns the routes check only), and commit the file. From
then on only new unused routes fail -- and a baseline line whose route has since been deleted, excluded or
used is reported as api-route-baseline-stale, so the list can only shrink.
Scans backend and frontend code for injection risks, and warns when a web project has no Content-Security-Policy.
bdt find-injection [PATHS...] # files/folders, default: current directory
bdt find-injection --diff [--base main] # only files changed on this branch + uncommitted/untracked
bdt find-injection --strict # also fail on "review" itemsFindings have two severities: error (a definite unsafe pattern, exit code 1) and review
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
code 0 unless --strict).
- SQL (Python
.execute()): f-string /%/ concatenation /.format()SQL is an error. SQL fromload_sql(),sql.SQL, sqlglot (expr.sql(),sqlglot.*),cast(LiteralString, <sqlglot expr>)(the cast is only as safe as its argument) or a function in the same file annotated-> LiteralStringis trusted; SQL from any other function call is asql-unverified-callreview item. (bdt lintaccepts the same trusted forms but never reports unverified calls.) - Other Python sinks:
eval/exec,os.system,subprocess(..., shell=True),yaml.loadwithout a safe loader,pickle.loads,Markup()/mark_safe(), Jinjaautoescape=False,render_template_string. - Frontend (TS/JS/JSX/Vue/Svelte/HTML):
innerHTML/outerHTML/insertAdjacentHTML,dangerouslySetInnerHTML,v-html,document.write,eval,new Function, stringsetTimeout/setInterval,javascript:URLs,postMessage(..., "*"),srcdoc, and<iframe>withoutsandbox(or withallow-scripts+allow-same-origin). - CSP:
csp-missingwhen a web project (frontend files or a Python web framework) has noContent-Security-Policyanywhere in the repo (header in code,<meta http-equiv>,staticwebapp.config.json, nginx/web.config, ...);csp-weakenedfor'unsafe-inline','unsafe-eval'or a wildcardscript-src. The CSP lookup always covers the whole repo, even with--diff.
Skipped automatically: test files, generated code, *.min.js files, the usual build/vendor directories (node_modules, .venv, dist, ...) and,
inside a git repo, anything ignored by .gitignore (pass --no-gitignore to scan it anyway).
Add more with --exclude (repeatable; a directory name at any depth, a root-relative path, or a glob
such as 'assets/**/*.js') or [tool.bdt.lint] exclude_dirs = [...] in pyproject.toml.
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
# bdt-lint: ignore <rule> (Python) or // bdt-lint: ignore <rule> (TS/JS) on, or directly above, the line.
Bump version in pyproject.toml as part of your PR, same as any other
change. Once that PR merges to main and the Python Test workflow passes
for that commit, .github/workflows/auto-release.yml automatically tags it
vX.Y.Z, cuts a GitHub Release (skipping if that version was already
released, e.g. a merge that didn't touch the version), and dispatches
python-publish.yml to publish it to PyPI — no manual release step, and no
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
this (see the comments at the top of auto-release.yml for the full
reasoning, since both were hit and confirmed the hard way):
- A release created with the default
GITHUB_TOKENdoes not trigger other workflows'release: publishedlisteners (an anti-recursion safeguard) —workflow_dispatchis the documented exception, soauto-release.ymldispatchespython-publish.ymldirectly (gh workflow run) instead of relying on the release to cascade into it. python-publish.ymldeliberately stays a plain, directly-triggered top-level workflow rather than somethingauto-release.ymlcalls viaworkflow_call: PyPI's OIDC trusted publishing does not support reusable/called workflows and silently rejects the token in that shape.
workflow_dispatch (or an actual GitHub UI release) on python-publish.yml
still works as a manual fallback if you ever need to re-publish a version
without going through auto-release.yml.