bmsdna-devtools 0.30.0__tar.gz → 0.31.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/PKG-INFO +159 -12
  2. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/README.md +157 -11
  3. bmsdna_devtools-0.31.0/bmsdna/devtools/_api_dump.py +58 -0
  4. bmsdna_devtools-0.31.0/bmsdna/devtools/api_usage.py +501 -0
  5. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/cli.py +162 -14
  6. bmsdna_devtools-0.31.0/bmsdna/devtools/dead_code.py +82 -0
  7. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/issue_do.py +11 -0
  8. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint_findings.py +2 -2
  9. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint_sql.py +7 -1
  10. bmsdna_devtools-0.31.0/bmsdna/devtools/lint_sql_files.py +149 -0
  11. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pr_build.py +13 -8
  12. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pr_info.py +9 -1
  13. bmsdna_devtools-0.31.0/bmsdna/devtools/translate.py +289 -0
  14. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/pyproject.toml +2 -1
  15. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/skills/bmsdna-devtools/SKILL.md +13 -3
  16. bmsdna_devtools-0.31.0/tests/test_api_usage.py +654 -0
  17. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_lint_sql.py +10 -0
  18. bmsdna_devtools-0.31.0/tests/test_lint_sql_files.py +215 -0
  19. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_build_flow.py +20 -0
  20. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_info.py +47 -5
  21. bmsdna_devtools-0.31.0/tests/test_translate.py +165 -0
  22. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/uv.lock +12 -1
  23. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/.github/workflows/auto-release.yml +0 -0
  24. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/.github/workflows/python-publish.yml +0 -0
  25. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/.github/workflows/python-test.yml +0 -0
  26. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/.gitignore +0 -0
  27. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/.python-version +0 -0
  28. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/__init__.py +0 -0
  29. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/ado_auth.py +0 -0
  30. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/ado_issue.py +0 -0
  31. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/app_service_logs.py +0 -0
  32. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/bdt_config.py +0 -0
  33. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/cli_tools.py +0 -0
  34. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/commit.py +0 -0
  35. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/env_config.py +0 -0
  36. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/find_injection.py +0 -0
  37. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/find_repo.py +0 -0
  38. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/gh_issue.py +0 -0
  39. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/gh_pr.py +0 -0
  40. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/gitrepo.py +0 -0
  41. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/injection_frontend.py +0 -0
  42. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/injection_python.py +0 -0
  43. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint.py +0 -0
  44. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint_models.py +0 -0
  45. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint_tooling.py +0 -0
  46. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/lint_typescript.py +0 -0
  47. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/logs.py +0 -0
  48. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  49. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pr_labels.py +0 -0
  50. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pr_markdown.py +0 -0
  51. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/pull.py +0 -0
  52. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/testdb.py +0 -0
  53. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/bmsdna/devtools/worktree.py +0 -0
  54. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/justfile +0 -0
  55. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/conftest.py +0 -0
  56. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_ado_auth.py +0 -0
  57. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_ado_issue.py +0 -0
  58. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_ado_issue_flow.py +0 -0
  59. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_app_service_logs.py +0 -0
  60. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_bdt_config.py +0 -0
  61. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_cleanup.py +0 -0
  62. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_commit.py +0 -0
  63. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_issue.py +0 -0
  64. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_pr_create.py +0 -0
  65. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_pr_id.py +0 -0
  66. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_pull.py +0 -0
  67. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_cli_tools.py +0 -0
  68. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_commit.py +0 -0
  69. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_env_config.py +0 -0
  70. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_find_injection.py +0 -0
  71. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_find_repo.py +0 -0
  72. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_gh_issue.py +0 -0
  73. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_gh_pr.py +0 -0
  74. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_gitrepo.py +0 -0
  75. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_issue_do.py +0 -0
  76. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_lint.py +0 -0
  77. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_lint_models.py +0 -0
  78. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_lint_tooling.py +0 -0
  79. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_lint_typescript.py +0 -0
  80. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_build.py +0 -0
  81. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_issue_link.py +0 -0
  82. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_labels.py +0 -0
  83. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pr_markdown.py +0 -0
  84. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_pull.py +0 -0
  85. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_testdb.py +0 -0
  86. {bmsdna_devtools-0.30.0 → bmsdna_devtools-0.31.0}/tests/test_worktree_clean.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: pgdevkit[db]>=0.7.1
7
7
  Requires-Dist: requests>=2.32.0
8
8
  Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
9
10
  Requires-Dist: typer>=0.26.7
10
11
  Provides-Extra: lint
11
12
  Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
@@ -18,7 +19,8 @@ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue
18
19
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
19
20
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
20
21
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
21
- TypeScript, and baseline tooling.
22
+ TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
23
+ backend routes nothing calls.
22
24
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
23
25
  remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
24
26
  Consolidates near-duplicate scripts that used to be copy-pasted across
@@ -48,11 +50,12 @@ Find the PR opened from the current branch and report build/check status
48
50
  GitHub — whichever `origin` points at.
49
51
 
50
52
  ```bash
51
- bdt pr status [--target-branch dev] [--wait]
53
+ bdt pr status [--target-branch BRANCH] [--wait]
52
54
  ```
53
55
 
54
- Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
55
- `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
56
+ The `--target` / `--target-branch` option of `pr create/retry/publish/update/comment/watch-deploy` and `commit`
57
+ defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. `pr status`, `pr info` and
58
+ `issue take` only look a PR up, so by default they don't filter on the target branch at all.
56
59
 
57
60
  If the PR can't be merged, that's reported immediately instead of polling
58
61
  for builds/checks that will never run — e.g. on Azure DevOps:
@@ -73,7 +76,7 @@ one is also waiting on approval.
73
76
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
74
77
  `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
75
78
  caller's own `az login` — never embed a PAT literal in a script or CI file.
76
- `--target-branch` selects which PR to look at (ADO's search API needs one).
79
+ `--target-branch` optionally restricts the lookup to PRs into that branch (default: any).
77
80
 
78
81
  **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
79
82
  PR opened from the current branch — `gh pr view` has no target-branch
@@ -91,7 +94,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
91
94
  ## `bdt pr info`
92
95
 
93
96
  ```bash
94
- bdt pr info [--json] [--pr-id N] [--target-branch dev]
97
+ bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
95
98
  ```
96
99
 
97
100
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -102,7 +105,7 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
102
105
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
103
106
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
104
107
 
105
- Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
108
+ Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
106
109
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
107
110
  work items are only those whose URL is in the PR description -- not ones linked via
108
111
  `bdt pr create --issue`.
@@ -323,12 +326,12 @@ last argument); extra args are passed through; `--dry-run` prints the command.
323
326
  It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
324
327
  on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
325
328
  passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
326
- 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 a claim.
329
+ 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`.
327
330
 
328
331
  ## `bdt issue take`
329
332
 
330
333
  ```bash
331
- bdt issue take [NUMBER] [--target-branch dev]
334
+ bdt issue take [NUMBER] [--target-branch BRANCH] [--force]
332
335
  ```
333
336
 
334
337
  Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
@@ -340,6 +343,43 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
340
343
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
341
344
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
342
345
 
346
+ ## `bdt translate`
347
+
348
+ One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
349
+ per-language `de.json` / `en.json` / ... files from it. The generated files are build artifacts --
350
+ add them to `.gitignore` and run `bdt translate` in your `just install` / build / CI steps.
351
+
352
+ ```toml
353
+ # translations.toml (keys with dots must be quoted when `nested = true`)
354
+ [ADD_BUTTON]
355
+ en = "Add"
356
+ de = "Hinzufügen"
357
+ fr = "Ajouter"
358
+ it = "Aggiungi"
359
+ server_only = true # optional: never emitted into the JSON files (backend-only text)
360
+ ```
361
+
362
+ ```toml
363
+ # pyproject.toml
364
+ [tool.bdt.translate]
365
+ file = "translations.toml" # default
366
+ languages = ["en", "de", "fr", "it"] # default
367
+ required_languages = ["en"] # default; `bdt translate add` fails without these
368
+ output = ["frontend/src/assets/i18n"] # directories receiving <lng>.json
369
+ nested = false # true: "a.b.c" keys become nested JSON objects
370
+ scan = ["frontend/src"] # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
371
+ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
372
+ ```
373
+
374
+ - A missing language falls back to `de`, then `en`, then the key itself.
375
+ - Keys used in code but absent from `translations.toml` are appended with an English placeholder and
376
+ the command exits 1 without generating anything -- fill in the translations and rerun.
377
+ - `bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi` adds a key to `translations.toml` and
378
+ regenerates all JSON files in one go (the `required_languages` are mandatory; `--force` overwrites an existing key).
379
+ - `bdt translate --check` writes nothing and exits 1 if code uses keys missing from the toml (for CI).
380
+ - `bdt translate --import` merges existing `<lng>.json` files into `translations.toml` once, to migrate
381
+ a repo that so far hand-maintained its JSON files; then `git rm --cached` them and ignore them.
382
+
343
383
  ## `bdt worktree`
344
384
 
345
385
  ```bash
@@ -571,8 +611,9 @@ bdt logs fetch --env prod --out logs/ --keep-archive
571
611
  ## `bdt lint`
572
612
 
573
613
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
574
- for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
575
- that the repo has its baseline tooling actually set up:
614
+ for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, hand-wired HTTP in
615
+ TypeScript, and that the repo has its baseline tooling actually set up. Unused `.sql` files and
616
+ dead backend routes are a separate command, [`bdt dead-code`](#bdt-dead-code):
576
617
 
577
618
  ```bash
578
619
  bdt lint # scan the current directory, recursively
@@ -661,6 +702,112 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
661
702
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
662
703
  `ImportError`) if it's ever missing.
663
704
 
705
+ ## `bdt dead-code`
706
+
707
+ Dead code a linter can't see, in one command and one config table, `[tool.bdt.dead_code]`:
708
+
709
+ - **`sql`** -- `.sql` files that no Python code loads;
710
+ - **`routes`** -- backend (FastAPI) routes that neither non-generated frontend code nor a `url_for(...)`
711
+ call uses.
712
+
713
+ Each check runs when it is configured (`sql_roots`, resp. `[[tool.bdt.dead_code.apps]]`); `--only sql` /
714
+ `--only routes` (repeatable) narrows a run, e.g. to keep the slow one -- `routes` imports the app, so run
715
+ `bdt` with the repo's own interpreter (`uv run bdt dead-code`) -- out of a prek hook. Exit code is 0 (clean), 1
716
+ (findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
717
+ within 5 minutes -- the error shows the import's output).
718
+
719
+ ### `sql` -- unreferenced `.sql` files
720
+
721
+ A `.sql` file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
722
+ folders that hold *loadable* SQL (not schema/migration scripts, which are applied rather than loaded) and every
723
+ file under them that nothing references is reported (`sql-file-unreferenced`):
724
+
725
+ ```toml
726
+ [tool.bdt.dead_code]
727
+ sql_roots = ["backend/db/queries"] # repo-relative; a typo'd root is itself reported
728
+ sql_loader_functions = ["load_sql"] # default; add your own loader's name if it differs
729
+ sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"] # globs for files reached some other way
730
+ # exclude_dirs = ["generated"] # extra directory names to skip, beyond .venv/node_modules/etc.
731
+ ```
732
+
733
+ A file counts as referenced by a literal `load_sql("topic", "name")` call (positional or `topic=`/`name=`;
734
+ topic = the file's parent directory, name = its stem); by a `load_sql("topic", some_var)` call in a Python file that
735
+ also contains the stem as a string literal (so `name = "a" if x else "b"` and lookup tables work);
736
+ by a string literal that is a path whose trailing segments equal the file's repo-relative path
737
+ (`get_sql_with_prm_list("backend/api/sql/x.sql")`); or by its bare filename as a literal in a
738
+ Python file under the SQL folder's parent (`_SQL_DIR / "x.sql"`). It never executes code, so a
739
+ file reached through a fully computed path is a false positive -- list it in
740
+ `sql_unreferenced_ignore`. References are searched across the whole repo, since the caller can live anywhere.
741
+ A Python file that can't be parsed (including syntax newer than the interpreter running `bdt`) is itself
742
+ reported as `sql-check-python-unparseable`, because references in it are unknown.
743
+
744
+ ### `routes` -- backend routes nobody uses
745
+
746
+ Backend (FastAPI) operations that **no non-generated frontend code calls and no backend code references by
747
+ name** -- dead routes that still have to be maintained, secured and tested.
748
+
749
+ ```toml
750
+ [[tool.bdt.dead_code.apps]]
751
+ name = "akeneo" # label used in the report
752
+ app = "main_app:app" # module:attribute, imported in a subprocess ...
753
+ app_dir = "akeneo_editor/backend" # ... with this directory (repo-relative) as cwd/import root
754
+ # openapi = "frontend/openapi.json" # alternative to `app`: a committed OpenAPI document
755
+ frontends = ["akeneo_editor/frontend/src"] # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
756
+ exclude_prefixes = ["/external_api"] # routes meant for other callers (external API, webhooks)
757
+ exclude_tags = ["agent"] # e.g. LLM/MCP tools
758
+ exclude_paths = ["/auth/*", "GET /health"] # fnmatch globs on "/path" or "METHOD /path"
759
+ baseline = "dead-code-baseline.txt" # optional ratchet, see below
760
+ # env = { SOME_REQUIRED_SETTING = "x" } # extra environment for importing the app
761
+ # exclude_frontend_globs = ["src/legacy/*"] # repo-relative frontend files to ignore as callers
762
+ # url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route
763
+ ```
764
+
765
+ Pair each backend app with *its own* frontends (one `[[...apps]]` entry per backend) -- a route
766
+ called only by another backend's frontend is still unused here.
767
+
768
+ The backend inventory is `app.openapi()` of the app **and of every mounted sub-app** (with the
769
+ mount prefix), so it needs no committed schema and can't go stale; routes with
770
+ `include_in_schema=False` are not considered.
771
+
772
+ **Generated code never counts as a caller.** A generated client lists *every* route, so it is
773
+ skipped (`generated/`, `*.gen.*`, `*.generated.*`, `api-types*`, `openapi_schema*`, `*.d.ts`), as are
774
+ tests and e2e specs (`*.test.*`, `*.spec.*`, `__tests__/`, `tests/`, `e2e/`) -- for TypeScript,
775
+ JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
776
+ non-generated code of one of the app's frontends has (strongest first):
777
+
778
+ - **sdk** -- a reference to a hey-api SDK function (read from the `sdk.gen.ts` under that same frontend
779
+ directory, matched on method *and* url) or one of its react-query helpers (`fooOptions`, `fooMutation`,
780
+ `fooQueryKey`, ...). Each `frontends` directory is scoped to its own SDK, so two apps that both generate
781
+ `listItems` aren't confused;
782
+ - **fetch** -- an openapi-fetch call `.GET("/path"` naming this method and path (a `DELETE` of the
783
+ same path with no call of its own is still reported);
784
+ - **url** -- a string/template literal equal to the path template (any method), e.g. a hand-written
785
+ `fetch(`/api/x/${id}`)`, `"/api/x/" + id` or an `<a href=...>` -- nested templates like
786
+ `${qs ? `?a=${b}` : ""}` are handled;
787
+ - **url-sfx** -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
788
+ or a `${base}` prefix).
789
+
790
+ **Routes the backend refers to by name are used, too.** Auth redirects and OAuth callbacks have no frontend caller
791
+ by nature -- the backend builds their URL: `request.url_for("auth_callback")`, `app.url_path_for("login")`,
792
+ `{{ url_for('login') }}` in a template. A string literal passed (first argument, or `name=`) to one of
793
+ `url_for_functions` in a non-test Python file or Jinja-style template (`.html`, `.jinja`, `.jinja2`, `.j2`) under the app's `app_dir`
794
+ marks the route of that name as used (`"mount:name"` for a mounted sub-app counts as `name`). The route name is recovered from
795
+ its `operationId`: FastAPI's default `name + path + method` form, a bare `name`, or `<prefix>-<name>` from a custom
796
+ `generate_unique_id`; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
797
+ in a comment counts too.
798
+
799
+ It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
800
+ SPA `<Link to="/users/${id}">`), and URLs assembled from non-literal pieces (including a non-literal route
801
+ name in `url_for`) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
802
+ that exposes no documented operations (e.g. one wrapped in middleware that hides `openapi()`) is a setup error,
803
+ not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
804
+ catch-all, service-to-service endpoints and an external API folder.
805
+
806
+ **Adopting it without fixing everything first:** set `baseline`, run
807
+ `uv run bdt dead-code --update-baseline` once (it concerns the `routes` check only), and commit the file. From
808
+ then on only *new* unused routes fail -- and a baseline line whose route has since been deleted, excluded or
809
+ used is reported as `api-route-baseline-stale`, so the list can only shrink.
810
+
664
811
  ## `bdt find-injection`
665
812
 
666
813
  Scans backend and frontend code for injection risks, and warns when a web project has no
@@ -5,7 +5,8 @@ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue
5
5
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
6
6
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
7
7
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
8
- TypeScript, and baseline tooling.
8
+ TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
9
+ backend routes nothing calls.
9
10
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
10
11
  remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
11
12
  Consolidates near-duplicate scripts that used to be copy-pasted across
@@ -35,11 +36,12 @@ Find the PR opened from the current branch and report build/check status
35
36
  GitHub — whichever `origin` points at.
36
37
 
37
38
  ```bash
38
- bdt pr status [--target-branch dev] [--wait]
39
+ bdt pr status [--target-branch BRANCH] [--wait]
39
40
  ```
40
41
 
41
- Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
42
- `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
42
+ The `--target` / `--target-branch` option of `pr create/retry/publish/update/comment/watch-deploy` and `commit`
43
+ defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. `pr status`, `pr info` and
44
+ `issue take` only look a PR up, so by default they don't filter on the target branch at all.
43
45
 
44
46
  If the PR can't be merged, that's reported immediately instead of polling
45
47
  for builds/checks that will never run — e.g. on Azure DevOps:
@@ -60,7 +62,7 @@ one is also waiting on approval.
60
62
  `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
61
63
  `AZURE_DEVOPS_EXT_PAT`/`AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
62
64
  caller's own `az login` — never embed a PAT literal in a script or CI file.
63
- `--target-branch` selects which PR to look at (ADO's search API needs one).
65
+ `--target-branch` optionally restricts the lookup to PRs into that branch (default: any).
64
66
 
65
67
  **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
66
68
  PR opened from the current branch — `gh pr view` has no target-branch
@@ -78,7 +80,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.)
78
80
  ## `bdt pr info`
79
81
 
80
82
  ```bash
81
- bdt pr info [--json] [--pr-id N] [--target-branch dev]
83
+ bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]
82
84
  ```
83
85
 
84
86
  One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
@@ -89,7 +91,7 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou
89
91
  from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
90
92
  `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
91
93
 
92
- Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
94
+ Azure DevOps limits: the PR is found by the current branch (into `--target-branch` if given, else any target),
93
95
  a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
94
96
  work items are only those whose URL is in the PR description -- not ones linked via
95
97
  `bdt pr create --issue`.
@@ -310,12 +312,12 @@ last argument); extra args are passed through; `--dry-run` prints the command.
310
312
  It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
311
313
  on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
312
314
  passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
313
- 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 a claim.
315
+ 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`.
314
316
 
315
317
  ## `bdt issue take`
316
318
 
317
319
  ```bash
318
- bdt issue take [NUMBER] [--target-branch dev]
320
+ bdt issue take [NUMBER] [--target-branch BRANCH] [--force]
319
321
  ```
320
322
 
321
323
  Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
@@ -327,6 +329,43 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
327
329
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
328
330
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
329
331
 
332
+ ## `bdt translate`
333
+
334
+ One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
335
+ per-language `de.json` / `en.json` / ... files from it. The generated files are build artifacts --
336
+ add them to `.gitignore` and run `bdt translate` in your `just install` / build / CI steps.
337
+
338
+ ```toml
339
+ # translations.toml (keys with dots must be quoted when `nested = true`)
340
+ [ADD_BUTTON]
341
+ en = "Add"
342
+ de = "Hinzufügen"
343
+ fr = "Ajouter"
344
+ it = "Aggiungi"
345
+ server_only = true # optional: never emitted into the JSON files (backend-only text)
346
+ ```
347
+
348
+ ```toml
349
+ # pyproject.toml
350
+ [tool.bdt.translate]
351
+ file = "translations.toml" # default
352
+ languages = ["en", "de", "fr", "it"] # default
353
+ required_languages = ["en"] # default; `bdt translate add` fails without these
354
+ output = ["frontend/src/assets/i18n"] # directories receiving <lng>.json
355
+ nested = false # true: "a.b.c" keys become nested JSON objects
356
+ scan = ["frontend/src"] # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
357
+ scan_jinja = ["backend/print"] # "KEY" | tr in .jinja2/.j2/.html
358
+ ```
359
+
360
+ - A missing language falls back to `de`, then `en`, then the key itself.
361
+ - Keys used in code but absent from `translations.toml` are appended with an English placeholder and
362
+ the command exits 1 without generating anything -- fill in the translations and rerun.
363
+ - `bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi` adds a key to `translations.toml` and
364
+ regenerates all JSON files in one go (the `required_languages` are mandatory; `--force` overwrites an existing key).
365
+ - `bdt translate --check` writes nothing and exits 1 if code uses keys missing from the toml (for CI).
366
+ - `bdt translate --import` merges existing `<lng>.json` files into `translations.toml` once, to migrate
367
+ a repo that so far hand-maintained its JSON files; then `git rm --cached` them and ignore them.
368
+
330
369
  ## `bdt worktree`
331
370
 
332
371
  ```bash
@@ -558,8 +597,9 @@ bdt logs fetch --env prod --out logs/ --keep-archive
558
597
  ## `bdt lint`
559
598
 
560
599
  Static checks (implementing [bmsuisse/skills#52](https://github.com/bmsuisse/skills/issues/52))
561
- for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, and
562
- that the repo has its baseline tooling actually set up:
600
+ for the `postgres-best-practices` skill's SQL rules, pydantic-model placement, hand-wired HTTP in
601
+ TypeScript, and that the repo has its baseline tooling actually set up. Unused `.sql` files and
602
+ dead backend routes are a separate command, [`bdt dead-code`](#bdt-dead-code):
563
603
 
564
604
  ```bash
565
605
  bdt lint # scan the current directory, recursively
@@ -648,6 +688,112 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
648
688
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
649
689
  `ImportError`) if it's ever missing.
650
690
 
691
+ ## `bdt dead-code`
692
+
693
+ Dead code a linter can't see, in one command and one config table, `[tool.bdt.dead_code]`:
694
+
695
+ - **`sql`** -- `.sql` files that no Python code loads;
696
+ - **`routes`** -- backend (FastAPI) routes that neither non-generated frontend code nor a `url_for(...)`
697
+ call uses.
698
+
699
+ Each check runs when it is configured (`sql_roots`, resp. `[[tool.bdt.dead_code.apps]]`); `--only sql` /
700
+ `--only routes` (repeatable) narrows a run, e.g. to keep the slow one -- `routes` imports the app, so run
701
+ `bdt` with the repo's own interpreter (`uv run bdt dead-code`) -- out of a prek hook. Exit code is 0 (clean), 1
702
+ (findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import
703
+ within 5 minutes -- the error shows the import's output).
704
+
705
+ ### `sql` -- unreferenced `.sql` files
706
+
707
+ A `.sql` file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the
708
+ folders that hold *loadable* SQL (not schema/migration scripts, which are applied rather than loaded) and every
709
+ file under them that nothing references is reported (`sql-file-unreferenced`):
710
+
711
+ ```toml
712
+ [tool.bdt.dead_code]
713
+ sql_roots = ["backend/db/queries"] # repo-relative; a typo'd root is itself reported
714
+ sql_loader_functions = ["load_sql"] # default; add your own loader's name if it differs
715
+ sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"] # globs for files reached some other way
716
+ # exclude_dirs = ["generated"] # extra directory names to skip, beyond .venv/node_modules/etc.
717
+ ```
718
+
719
+ A file counts as referenced by a literal `load_sql("topic", "name")` call (positional or `topic=`/`name=`;
720
+ topic = the file's parent directory, name = its stem); by a `load_sql("topic", some_var)` call in a Python file that
721
+ also contains the stem as a string literal (so `name = "a" if x else "b"` and lookup tables work);
722
+ by a string literal that is a path whose trailing segments equal the file's repo-relative path
723
+ (`get_sql_with_prm_list("backend/api/sql/x.sql")`); or by its bare filename as a literal in a
724
+ Python file under the SQL folder's parent (`_SQL_DIR / "x.sql"`). It never executes code, so a
725
+ file reached through a fully computed path is a false positive -- list it in
726
+ `sql_unreferenced_ignore`. References are searched across the whole repo, since the caller can live anywhere.
727
+ A Python file that can't be parsed (including syntax newer than the interpreter running `bdt`) is itself
728
+ reported as `sql-check-python-unparseable`, because references in it are unknown.
729
+
730
+ ### `routes` -- backend routes nobody uses
731
+
732
+ Backend (FastAPI) operations that **no non-generated frontend code calls and no backend code references by
733
+ name** -- dead routes that still have to be maintained, secured and tested.
734
+
735
+ ```toml
736
+ [[tool.bdt.dead_code.apps]]
737
+ name = "akeneo" # label used in the report
738
+ app = "main_app:app" # module:attribute, imported in a subprocess ...
739
+ app_dir = "akeneo_editor/backend" # ... with this directory (repo-relative) as cwd/import root
740
+ # openapi = "frontend/openapi.json" # alternative to `app`: a committed OpenAPI document
741
+ frontends = ["akeneo_editor/frontend/src"] # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
742
+ exclude_prefixes = ["/external_api"] # routes meant for other callers (external API, webhooks)
743
+ exclude_tags = ["agent"] # e.g. LLM/MCP tools
744
+ exclude_paths = ["/auth/*", "GET /health"] # fnmatch globs on "/path" or "METHOD /path"
745
+ baseline = "dead-code-baseline.txt" # optional ratchet, see below
746
+ # env = { SOME_REQUIRED_SETTING = "x" } # extra environment for importing the app
747
+ # exclude_frontend_globs = ["src/legacy/*"] # repo-relative frontend files to ignore as callers
748
+ # url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route
749
+ ```
750
+
751
+ Pair each backend app with *its own* frontends (one `[[...apps]]` entry per backend) -- a route
752
+ called only by another backend's frontend is still unused here.
753
+
754
+ The backend inventory is `app.openapi()` of the app **and of every mounted sub-app** (with the
755
+ mount prefix), so it needs no committed schema and can't go stale; routes with
756
+ `include_in_schema=False` are not considered.
757
+
758
+ **Generated code never counts as a caller.** A generated client lists *every* route, so it is
759
+ skipped (`generated/`, `*.gen.*`, `*.generated.*`, `api-types*`, `openapi_schema*`, `*.d.ts`), as are
760
+ tests and e2e specs (`*.test.*`, `*.spec.*`, `__tests__/`, `tests/`, `e2e/`) -- for TypeScript,
761
+ JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when
762
+ non-generated code of one of the app's frontends has (strongest first):
763
+
764
+ - **sdk** -- a reference to a hey-api SDK function (read from the `sdk.gen.ts` under that same frontend
765
+ directory, matched on method *and* url) or one of its react-query helpers (`fooOptions`, `fooMutation`,
766
+ `fooQueryKey`, ...). Each `frontends` directory is scoped to its own SDK, so two apps that both generate
767
+ `listItems` aren't confused;
768
+ - **fetch** -- an openapi-fetch call `.GET("/path"` naming this method and path (a `DELETE` of the
769
+ same path with no call of its own is still reported);
770
+ - **url** -- a string/template literal equal to the path template (any method), e.g. a hand-written
771
+ `fetch(`/api/x/${id}`)`, `"/api/x/" + id` or an `<a href=...>` -- nested templates like
772
+ `${qs ? `?a=${b}` : ""}` are handled;
773
+ - **url-sfx** -- a literal (two or more segments) that is a suffix of the route (a client with a base URL,
774
+ or a `${base}` prefix).
775
+
776
+ **Routes the backend refers to by name are used, too.** Auth redirects and OAuth callbacks have no frontend caller
777
+ by nature -- the backend builds their URL: `request.url_for("auth_callback")`, `app.url_path_for("login")`,
778
+ `{{ url_for('login') }}` in a template. A string literal passed (first argument, or `name=`) to one of
779
+ `url_for_functions` in a non-test Python file or Jinja-style template (`.html`, `.jinja`, `.jinja2`, `.j2`) under the app's `app_dir`
780
+ marks the route of that name as used (`"mount:name"` for a mounted sub-app counts as `name`). The route name is recovered from
781
+ its `operationId`: FastAPI's default `name + path + method` form, a bare `name`, or `<prefix>-<name>` from a custom
782
+ `generate_unique_id`; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call
783
+ in a comment counts too.
784
+
785
+ It is a heuristic: no type information, a URL literal matches every method of that path (and so does an
786
+ SPA `<Link to="/users/${id}">`), and URLs assembled from non-literal pieces (including a non-literal route
787
+ name in `url_for`) or handed to the client by the server are invisible -- exclude those routes explicitly. An app
788
+ that exposes no documented operations (e.g. one wrapped in middleware that hides `openapi()`) is a setup error,
789
+ not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA
790
+ catch-all, service-to-service endpoints and an external API folder.
791
+
792
+ **Adopting it without fixing everything first:** set `baseline`, run
793
+ `uv run bdt dead-code --update-baseline` once (it concerns the `routes` check only), and commit the file. From
794
+ then on only *new* unused routes fail -- and a baseline line whose route has since been deleted, excluded or
795
+ used is reported as `api-route-baseline-stale`, so the list can only shrink.
796
+
651
797
  ## `bdt find-injection`
652
798
 
653
799
  Scans backend and frontend code for injection risks, and warns when a web project has no
@@ -0,0 +1,58 @@
1
+ """Standalone (stdlib-only) helper of the `routes` check of `bdt dead-code`: list the documented HTTP operations of a
2
+ FastAPI-like app, recursing into mounted sub-apps.
3
+
4
+ It is executed in the *target repo's* interpreter via `python -c <this file's source> module:attr out.json`
5
+ and must therefore never import `bmsdna.devtools`: the working directory is first on `sys.path` there, and a repo may
6
+ ship its own top-level `bmsdna` package (CCMT2 does) that would shadow ours. `api_usage` imports it normally for
7
+ `ops_from_doc`, so the OpenAPI-document parsing exists exactly once.
8
+ """
9
+
10
+ import importlib
11
+ import json
12
+ import sys
13
+
14
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
15
+
16
+
17
+ def ops_from_doc(doc, mount=""):
18
+ """Operations of one OpenAPI document as dicts, with `mount` prepended to every path."""
19
+ ops = []
20
+ for path, item in (doc.get("paths") or {}).items():
21
+ for method, op in item.items():
22
+ if method in HTTP_METHODS and isinstance(op, dict):
23
+ ops.append(
24
+ {
25
+ "method": method.upper(),
26
+ "path": mount + path,
27
+ "tags": list(op.get("tags") or []),
28
+ "mount": mount,
29
+ "operation_id": str(op.get("operationId") or ""),
30
+ }
31
+ )
32
+ return ops
33
+
34
+
35
+ def collect(app, mount=""):
36
+ """Operations of `app` and, recursively, of every mounted sub-app. Uses only `app.openapi()` and the
37
+ `.path`/`.app` attributes of mount routes -- not FastAPI internals, which change between releases (FastAPI 0.141 wraps
38
+ included routers in lazy objects that `app.routes` no longer lists as plain routes)."""
39
+ openapi = getattr(app, "openapi", None)
40
+ if not callable(openapi):
41
+ return []
42
+ ops = ops_from_doc(openapi(), mount)
43
+ for route in getattr(app, "routes", None) or []:
44
+ sub = getattr(route, "app", None)
45
+ if sub is not None and callable(getattr(sub, "openapi", None)) and isinstance(getattr(route, "path", None), str):
46
+ ops.extend(collect(sub, mount + route.path.rstrip("/")))
47
+ return ops
48
+
49
+
50
+ def main(argv):
51
+ module_name, _, attr = argv[0].partition(":")
52
+ app = getattr(importlib.import_module(module_name), attr)
53
+ with open(argv[1], "w", encoding="utf-8") as out:
54
+ json.dump(collect(app), out)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main(sys.argv[1:])