bmsdna-devtools 0.34.0__tar.gz → 0.36.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 (88) hide show
  1. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/PKG-INFO +32 -6
  2. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/README.md +31 -5
  3. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli.py +2 -1
  4. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql.py +222 -33
  5. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/skills/bmsdna-devtools/SKILL.md +2 -2
  7. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_find_injection.py +26 -0
  8. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql.py +476 -0
  9. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/uv.lock +1 -1
  10. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/_api_dump.py +0 -0
  17. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_issue.py +0 -0
  19. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/api_usage.py +0 -0
  20. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/app_service_logs.py +0 -0
  21. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/bdt_config.py +0 -0
  22. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli_tools.py +0 -0
  23. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/commit.py +0 -0
  24. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/dead_code.py +0 -0
  25. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_files.py +0 -0
  27. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_injection.py +0 -0
  28. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_repo.py +0 -0
  29. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_issue.py +0 -0
  30. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_pr.py +0 -0
  31. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gitrepo.py +0 -0
  32. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_frontend.py +0 -0
  33. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_python.py +0 -0
  34. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/issue_do.py +0 -0
  35. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint.py +0 -0
  36. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_findings.py +0 -0
  37. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_models.py +0 -0
  38. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  39. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_tooling.py +0 -0
  40. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_typescript.py +0 -0
  41. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/logs.py +0 -0
  42. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_build.py +0 -0
  43. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_info.py +0 -0
  44. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  45. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_labels.py +0 -0
  46. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_markdown.py +0 -0
  47. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pull.py +0 -0
  48. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/testdb.py +0 -0
  49. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/translate.py +0 -0
  50. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/worktree.py +0 -0
  51. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/justfile +0 -0
  52. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/conftest.py +0 -0
  53. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_auth.py +0 -0
  54. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue.py +0 -0
  55. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue_flow.py +0 -0
  56. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_api_usage.py +0 -0
  57. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_app_service_logs.py +0 -0
  58. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_bdt_config.py +0 -0
  59. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_cleanup.py +0 -0
  60. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_commit.py +0 -0
  61. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_issue.py +0 -0
  62. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_create.py +0 -0
  63. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_id.py +0 -0
  64. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pull.py +0 -0
  65. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_tools.py +0 -0
  66. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_commit.py +0 -0
  67. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_env_config.py +0 -0
  68. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_env_files.py +0 -0
  69. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_find_repo.py +0 -0
  70. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gh_issue.py +0 -0
  71. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gh_pr.py +0 -0
  72. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gitrepo.py +0 -0
  73. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_issue_do.py +0 -0
  74. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint.py +0 -0
  75. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_models.py +0 -0
  76. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql_files.py +0 -0
  77. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_tooling.py +0 -0
  78. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_typescript.py +0 -0
  79. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build.py +0 -0
  80. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build_flow.py +0 -0
  81. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_info.py +0 -0
  82. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_issue_link.py +0 -0
  83. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_labels.py +0 -0
  84. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_markdown.py +0 -0
  85. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pull.py +0 -0
  86. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_testdb.py +0 -0
  87. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_translate.py +0 -0
  88. {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_worktree_clean.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.34.0
3
+ Version: 0.36.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
@@ -18,7 +18,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
18
18
  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
19
19
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
20
20
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
21
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
21
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
22
22
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
23
23
  backend routes nothing calls.
24
24
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -630,12 +630,36 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
630
630
  bdt lint --no-tooling-check # skip the tooling-config check for this run
631
631
  ```
632
632
 
633
- Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
633
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
634
634
  `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
635
635
  literal or f-string/concatenation/`%`-format expression is checked (an
636
636
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
637
637
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
638
- it can't see through):
638
+ it can't see through).
639
+
640
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
641
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
642
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
643
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
644
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
645
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
646
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
647
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
648
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
649
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
650
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
651
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
652
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
653
+ only as safe as `x`.
654
+
655
+ pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
656
+ exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
657
+ `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
658
+ recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
659
+ import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
660
+ project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
661
+ instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
662
+ `PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
639
663
 
640
664
  - **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
641
665
  JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
@@ -648,6 +672,8 @@ it can't see through):
648
672
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
649
673
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
650
674
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
675
+ A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
676
+ assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
651
677
  - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
652
678
  sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
653
679
  The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
@@ -835,8 +861,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
835
861
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
836
862
  code 0 unless `--strict`).
837
863
 
838
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
839
- SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
864
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
865
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
840
866
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
841
867
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
842
868
  reports unverified calls.)
@@ -4,7 +4,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
4
4
  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
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
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
7
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
8
8
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
9
9
  backend routes nothing calls.
10
10
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -616,12 +616,36 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
616
616
  bdt lint --no-tooling-check # skip the tooling-config check for this run
617
617
  ```
618
618
 
619
- Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
619
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
620
620
  `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
621
621
  literal or f-string/concatenation/`%`-format expression is checked (an
622
622
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
623
623
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
624
- it can't see through):
624
+ it can't see through).
625
+
626
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
627
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
628
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
629
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
630
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
631
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
632
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
633
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
634
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
635
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
636
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
637
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
638
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
639
+ only as safe as `x`.
640
+
641
+ pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
642
+ exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
643
+ `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
644
+ recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
645
+ import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
646
+ project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
647
+ instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
648
+ `PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
625
649
 
626
650
  - **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
627
651
  JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
@@ -634,6 +658,8 @@ it can't see through):
634
658
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
635
659
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
636
660
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
661
+ A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
662
+ assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
637
663
  - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
638
664
  sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
639
665
  The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
@@ -821,8 +847,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
821
847
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
822
848
  code 0 unless `--strict`).
823
849
 
824
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
825
- SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
850
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
851
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
826
852
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
827
853
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
828
854
  reports unverified calls.)
@@ -1093,7 +1093,8 @@ def lint(
1093
1093
  ),
1094
1094
  ) -> None:
1095
1095
  """Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call and pgdevkit
1096
- `fetch_all`/`fetch_one`/`fetch_scalar`/`PostgresJsonResponse` call
1096
+ `fetch_all`/`fetch_one`/`fetch_scalar`/`execute`/`PostgresJsonResponse` call (and arrowbricks'
1097
+ `execute_streamed`/`query_agent`/`stream_query_json`, injection rules only)
1097
1098
  (must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
1098
1099
  query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
1099
1100
  directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
@@ -1,6 +1,6 @@
1
1
  """AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
2
- every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
3
- `PostgresJsonResponse`) is classified by how its SQL argument was
2
+ every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
3
+ `PostgresJsonResponse`, and arrowbricks' `execute_streamed`/`query_agent`/`stream_query_json`) is classified by how its SQL argument was
4
4
  built, then checked against the `postgres-best-practices` skill's rules
5
5
  (../../skills/postgres-best-practices in a checkout of bmsuisse/skills).
6
6
 
@@ -24,10 +24,11 @@ than guessed at -- a linter that can't be sure must stay quiet, not noisy.
24
24
  from __future__ import annotations
25
25
 
26
26
  import ast
27
+ import itertools
27
28
  import re
28
29
  import sys
29
30
  from collections.abc import Mapping
30
- from dataclasses import dataclass, field
31
+ from dataclasses import dataclass, field, replace
31
32
  from pathlib import Path
32
33
 
33
34
  from .lint_findings import Finding
@@ -63,10 +64,18 @@ _POSITIONAL_PARAM_RE = re.compile(r"(?<!%)%s\b")
63
64
  # Placeholder substituted for a non-literal fragment (an f-string {expr}, a concatenated
64
65
  # non-constant operand, a str.format() {field}) before attempting an sqlglot parse -- padded
65
66
  # with spaces so it never merges into a neighboring identifier/keyword, and looks like an
66
- # ordinary identifier so the parse doesn't fail on the substitution itself.
67
+ # ordinary identifier so the parse doesn't fail on the substitution itself (see
68
+ # `_PROBE_SLOT_VARIANTS`/`_parse_probe_text` for the fallbacks when an identifier doesn't fit).
67
69
  _PROBE_PLACEHOLDER = " __X__ "
68
70
  _FORMAT_FIELD_RE = re.compile(r"\{[^{}]*\}")
69
71
 
72
+ # A dynamic fragment isn't always an identifier/expression: `SET {assignments}, {extra}` where `extra` is
73
+ # empty or `"is_approved = false,"`, or an optional `{where}` clause. When the probe with every fragment as an
74
+ # identifier doesn't parse, each fragment is retried as "nothing" and as an assignment, all combinations
75
+ # (only up to _MAX_PROBE_SLOTS fragments, so the number of parses stays small).
76
+ _PROBE_SLOT_VARIANTS = (_PROBE_PLACEHOLDER, " ", " __X__ = 1 ")
77
+ _MAX_PROBE_SLOTS = 4
78
+
70
79
  _MAX_RESOLVE_HOPS = 5
71
80
 
72
81
 
@@ -127,6 +136,15 @@ class _Trust:
127
136
  sqlglot_names: frozenset[str] = frozenset()
128
137
  literal_funcs: frozenset[str] = frozenset()
129
138
  sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
139
+ pgdevkit_sinks: frozenset[str] = frozenset() # local names bound by `from pgdevkit[.x] import <sql-taking helper>`
140
+ arrowbricks_modules: frozenset[str] = frozenset() # local names bound by `import arrowbricks[.x] [as y]`
141
+ arrowbricks_file: bool = False # the file imports arrowbricks (`import arrowbricks` / `from arrowbricks[.x] import ...`)
142
+ arrowbricks_stream_funcs: frozenset[str] = frozenset() # local names of the free `stream_query_json(client, sql)`
143
+ # Per-call, set by the visitor (see `_call_trust`): this sink's SQL is Databricks SQL, so a probe that does not parse
144
+ # as postgres is retried as Databricks (backticks, `:name` markers, QUALIFY, ...) ...
145
+ databricks: bool = False
146
+ # ... and the postgres house style (inline-complexity, `%s` params, forbidden joins) is not applied to it.
147
+ postgres_rules: bool = True
130
148
 
131
149
 
132
150
  _NO_TRUST = _Trust()
@@ -166,10 +184,13 @@ def _is_trusted_sql_call(node: ast.AST, trust: _Trust = _NO_TRUST) -> bool:
166
184
 
167
185
 
168
186
  def _literal_string_cast_inner(node: ast.Call) -> ast.expr | None:
169
- """`cast(LiteralString, x)` -> `x`. The cast itself proves nothing: it is only as safe as `x`."""
187
+ """`cast(LiteralString, x)` -> `x`, and arrowbricks' `unsafe_sql(x)` (its no-op spelling of the same cast) -> `x`.
188
+ The cast itself proves nothing: it is only as safe as `x`."""
170
189
  func = node.func
171
- is_cast = (isinstance(func, ast.Name) and func.id == "cast") or (isinstance(func, ast.Attribute) and func.attr == "cast")
172
- if is_cast and len(node.args) == 2 and _is_literal_string_ref(node.args[0]):
190
+ name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
191
+ if name == "unsafe_sql" and len(node.args) == 1 and not node.keywords:
192
+ return node.args[0]
193
+ if name == "cast" and len(node.args) == 2 and _is_literal_string_ref(node.args[0]):
173
194
  return node.args[1]
174
195
  return None
175
196
 
@@ -185,19 +206,52 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
185
206
  return None
186
207
 
187
208
 
209
+ def _is_pgdevkit_module(module: str | None) -> bool:
210
+ return module is not None and (module == "pgdevkit" or module.startswith("pgdevkit."))
211
+
212
+
213
+ def _is_arrowbricks_module(module: str | None) -> bool:
214
+ return module is not None and (module == "arrowbricks" or module.startswith("arrowbricks."))
215
+
216
+
188
217
  def _file_trust(tree: ast.AST) -> _Trust:
189
218
  literal_funcs: set[str] = set()
190
219
  sqlglot_names: set[str] = set()
191
220
  sqlglot_aliases: dict[str, str] = {}
221
+ pgdevkit_sinks: set[str] = set()
222
+ arrowbricks_modules: set[str] = set()
223
+ arrowbricks_stream_funcs: set[str] = set()
224
+ arrowbricks_file = False
192
225
  for node in ast.walk(tree):
193
226
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
194
227
  literal_funcs.add(node.name)
195
228
  elif isinstance(node, ast.Import):
196
229
  sqlglot_names.update((a.asname or a.name).split(".")[0] for a in node.names if a.name.split(".")[0] == "sqlglot")
230
+ for alias in node.names:
231
+ if _is_arrowbricks_module(alias.name):
232
+ arrowbricks_file = True
233
+ arrowbricks_modules.add(alias.asname or alias.name.split(".")[0])
197
234
  elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
198
235
  sqlglot_names.update(a.asname or a.name for a in node.names)
199
236
  sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
200
- return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs), sqlglot_aliases)
237
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_pgdevkit_module(node.module):
238
+ pgdevkit_sinks.update(a.asname or a.name for a in node.names if a.name in _PGDEVKIT_SQL_CALLEES)
239
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_arrowbricks_module(node.module):
240
+ arrowbricks_file = True
241
+ for alias in node.names:
242
+ if alias.name == "stream_query_json":
243
+ arrowbricks_stream_funcs.add(alias.asname or alias.name)
244
+ elif alias.name == "_streaming": # the module that defines the free function
245
+ arrowbricks_modules.add(alias.asname or alias.name)
246
+ return _Trust(
247
+ frozenset(sqlglot_names),
248
+ frozenset(literal_funcs),
249
+ sqlglot_aliases,
250
+ frozenset(pgdevkit_sinks),
251
+ frozenset(arrowbricks_modules),
252
+ arrowbricks_file,
253
+ frozenset(arrowbricks_stream_funcs),
254
+ )
201
255
 
202
256
 
203
257
  def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
@@ -276,12 +330,77 @@ _FSTRING_FIX = (
276
330
  "`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
277
331
  )
278
332
  _GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
333
+ _ARROWBRICKS_FSTRING_FIX = (
334
+ "Change the `f` prefix to `t` (arrowbricks t-string, Python 3.14+): `{value}` is bound as a named parameter and "
335
+ "`{name:i}` quotes a table/column identifier. Or keep the SQL literal and pass its `:name` markers' values as "
336
+ "`parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`)."
337
+ )
338
+ _ARROWBRICKS_GENERIC_FIX = (
339
+ "Use an arrowbricks t-string (Python 3.14+: `{value}` binds a named parameter, `{name:i}` quotes an identifier), or a "
340
+ "literal SQL string with `parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`) for its `:name` markers."
341
+ )
342
+ # A plain `.execute()` in a file that imports arrowbricks may be either driver's cursor: name neither.
343
+ _MIXED_FIX = (
344
+ "Change the `f` prefix to `t` (psycopg/arrowbricks t-string, Python 3.14+): `{value}` becomes a bound parameter and "
345
+ "`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
346
+ )
347
+
348
+
349
+ # Keyword that must literally be in the text before a variant reading (a fragment as nothing/an assignment) may
350
+ # count: sqlglot is lenient enough to read prose like "Update to version {v}" as an UPDATE with the fragment gone.
351
+ _VARIANT_REQUIRED_KEYWORD: dict[type, re.Pattern[str]] = {}
352
+ if exp is not None:
353
+ _VARIANT_REQUIRED_KEYWORD = {
354
+ exp.Select: re.compile(r"(?i)\bfrom\b"),
355
+ exp.Union: re.compile(r"(?i)\bfrom\b"),
356
+ exp.Insert: re.compile(r"(?i)\binto\b"),
357
+ exp.Update: re.compile(r"(?i)\bset\b"),
358
+ exp.Delete: re.compile(r"(?i)\bfrom\b"),
359
+ exp.Merge: re.compile(r"(?i)\busing\b"),
360
+ }
361
+
362
+
363
+ def _parse_any_dialect(text: str, databricks: bool) -> "exp.Expression | None":
364
+ parsed = parse_sql_text(text)
365
+ if parsed is None and databricks:
366
+ parsed = parse_sql_text(text, "databricks")
367
+ return parsed
368
+
369
+
370
+ def _parse_probe_text(text: str, databricks: bool = False) -> "exp.Expression | None":
371
+ """`parse_sql_text` for text with `_PROBE_PLACEHOLDER` standing in for dynamic fragments: the placeholder
372
+ as an identifier first, then (see `_PROBE_SLOT_VARIANTS`) every fragment as nothing/an assignment -- the
373
+ latter only when the literal text carries the statement's own keyword (`_VARIANT_REQUIRED_KEYWORD`)."""
374
+ parsed = _parse_any_dialect(text, databricks)
375
+ if parsed is not None:
376
+ return parsed
377
+ parts = text.split(_PROBE_PLACEHOLDER)
378
+ slots = len(parts) - 1
379
+ if not 1 <= slots <= _MAX_PROBE_SLOTS:
380
+ return None
381
+ literal_text = " ".join(parts)
382
+ for combination in itertools.product(_PROBE_SLOT_VARIANTS, repeat=slots):
383
+ if all(c == _PROBE_PLACEHOLDER for c in combination):
384
+ continue # that's `text` itself, already tried
385
+ candidate = "".join(a + b for a, b in itertools.zip_longest(parts, combination, fillvalue=""))
386
+ parsed = _parse_any_dialect(candidate, databricks)
387
+ if parsed is None:
388
+ continue
389
+ keyword = next((rx for t, rx in _VARIANT_REQUIRED_KEYWORD.items() if isinstance(parsed, t)), None)
390
+ if keyword is not None and keyword.search(literal_text):
391
+ return parsed
392
+ return None
279
393
 
280
394
 
281
- def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
282
- if parse_sql_text(text) is None:
395
+ def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str, trust: _Trust = _NO_TRUST) -> list[Finding]:
396
+ if _parse_probe_text(text, trust.databricks) is None:
283
397
  return []
284
- fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
398
+ if trust.databricks and not trust.postgres_rules:
399
+ fix = _ARROWBRICKS_FSTRING_FIX if rule == "sql-fstring-injection" else _ARROWBRICKS_GENERIC_FIX
400
+ elif trust.databricks:
401
+ fix = _MIXED_FIX
402
+ else:
403
+ fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
285
404
  return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
286
405
 
287
406
 
@@ -360,30 +479,30 @@ def _check_resolved_candidate(
360
479
  if _is_load_sql_call(resolved) or _is_sql_composed_call(resolved) or _is_trusted_sql_call(resolved, trust):
361
480
  return []
362
481
  if isinstance(resolved, getattr(ast, "TemplateStr", ())):
363
- return [] # psycopg t-string: params are always bound, never interpolated
482
+ return [] # psycopg/arrowbricks t-string: params are always bound, never interpolated
364
483
 
365
484
  if isinstance(resolved, ast.JoinedStr):
366
- return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string")
485
+ return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string", trust)
367
486
 
368
487
  if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Mod):
369
488
  left = _resolve_single(resolved.left, lookup)
370
489
  if isinstance(left, ast.Constant) and isinstance(left.value, str):
371
- return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator")
490
+ return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator", trust)
372
491
  return []
373
492
 
374
493
  if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Add):
375
494
  text = _concat_probe_text(resolved, lookup)
376
- return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation") if text is not None else []
495
+ return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation", trust) if text is not None else []
377
496
 
378
497
  if isinstance(resolved, ast.Call) and isinstance(resolved.func, ast.Attribute) and resolved.func.attr == "format":
379
498
  template = _resolve_single(resolved.func.value, lookup)
380
499
  if isinstance(template, ast.Constant) and isinstance(template.value, str):
381
500
  text = _FORMAT_FIELD_RE.sub(_PROBE_PLACEHOLDER, template.value)
382
- return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`")
501
+ return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`", trust)
383
502
  return []
384
503
 
385
504
  if isinstance(resolved, ast.Constant) and isinstance(resolved.value, str):
386
- return _literal_findings(resolved.value, path, lineno)
505
+ return _literal_findings(resolved.value, path, lineno) if trust.postgres_rules else []
387
506
 
388
507
  if isinstance(resolved, ast.Call):
389
508
  cast_inner = _literal_string_cast_inner(resolved)
@@ -418,7 +537,7 @@ def _unverified_cast_finding(path: Path, lineno: int) -> Finding:
418
537
  path,
419
538
  lineno,
420
539
  "sql-unverified-cast",
421
- "`cast(LiteralString, ...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
540
+ "`cast(LiteralString, ...)`/`unsafe_sql(...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
422
541
  "cannot carry user-controlled text (the cast doesn't make it safe); pass a sqlglot expression, or add "
423
542
  "`# bdt-lint: ignore sql-unverified-cast`.",
424
543
  severity="review",
@@ -455,26 +574,37 @@ def _is_sql_named(name: str) -> bool:
455
574
  return lowered == "sql" or lowered.endswith("_sql")
456
575
 
457
576
 
458
- # pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and `pgdevkit.fastapi.PostgresJsonResponse` run their first
459
- # argument as SQL, exactly like `.execute()`, so they get the same rules (plain functions / a class, so matched by
460
- # bare name too). Matched by name only, so not covered: an alias (`as PJR`), a subclass under another name (the
461
- # documented `class AppJson(PostgresJsonResponse): expose_errors = ...`), and the pre-pgdevkit
577
+ # pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and `pgdevkit.fastapi.PostgresJsonResponse` run their
578
+ # first argument as SQL, exactly like `.execute()`, so they get the same rules. As an attribute (`db.fetch_all(...)`,
579
+ # `pgdevkit.db.execute(...)`) they are matched by name only, like `.execute()` itself.
580
+ _PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "execute", "PostgresJsonResponse"})
581
+ # A bare `execute(...)` is far too generic a name (sqlite helpers, subprocess wrappers, ...): it counts only in a file
582
+ # that imports it from `pgdevkit`/`pgdevkit.db` (also under an alias: `from pgdevkit.db import execute as run`; see
583
+ # `_file_trust`). The import is tracked file-wide, so a local def/parameter named like it is not told apart. The sqlglot
584
+ # DML gate still applies on top. The other callees are matched by bare name too, and by their pgdevkit import alias.
585
+ # Not covered: a re-export through the project's own module (`from app.db import execute`), a subclass of
586
+ # PostgresJsonResponse under another name (the documented `class AppJson(PostgresJsonResponse)`), and the pre-pgdevkit
462
587
  # `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
463
588
  # only gets checked once that call site moves to pgdevkit.
464
- _PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "PostgresJsonResponse"})
589
+ _IMPORT_GATED_BARE_CALLEES = frozenset({"execute"})
590
+ _BARE_NAME_SQL_CALLEES = _PGDEVKIT_SQL_CALLEES - _IMPORT_GATED_BARE_CALLEES
591
+
592
+
593
+ def _is_pgdevkit_sink_call(func: ast.expr, trust: _Trust) -> bool:
594
+ if isinstance(func, ast.Name):
595
+ return func.id in _BARE_NAME_SQL_CALLEES or func.id in trust.pgdevkit_sinks
596
+ return isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
465
597
 
466
598
 
467
- def _execute_query_arg(call: ast.Call) -> ast.expr | None:
599
+ def _execute_query_arg(call: ast.Call, trust: _Trust = _NO_TRUST) -> ast.expr | None:
468
600
  func = call.func
469
601
  is_execute = isinstance(func, ast.Attribute) and func.attr in ("execute", "executemany")
470
- is_fetch_helper = (isinstance(func, ast.Name) and func.id in _PGDEVKIT_SQL_CALLEES) or (
471
- isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
472
- )
473
- if not (is_execute or is_fetch_helper):
602
+ if not (is_execute or _is_pgdevkit_sink_call(func, trust)):
474
603
  return None
475
604
  if call.args:
476
605
  return call.args[0]
477
- return next((kw.value for kw in call.keywords if kw.arg == "query"), None)
606
+ keywords = ("query", "sql") if trust.arrowbricks_file else ("query",) # arrowbricks' Cursor.execute(sql=...)
607
+ return next((kw.value for kw in call.keywords if kw.arg in keywords), None)
478
608
 
479
609
 
480
610
  # sqlglot's builders parse plain strings as SQL (`select(f"a, {x}")`, `.where(f"id = {x}")`), so text
@@ -568,6 +698,52 @@ def _sqlglot_builder_findings(
568
698
  return []
569
699
 
570
700
 
701
+ # arrowbricks' (Databricks SQL client) calls that take SQL: `Cursor.execute_streamed(sql, ...)`,
702
+ # `DatabricksClient.query_agent(sql, ...)`, `DatabricksClient.stream_query_json(sql, ...)` and the free function
703
+ # `arrowbricks.stream_query_json(client, sql, ...)`. (`Cursor.execute` is already matched as any `.execute()`.) The names are
704
+ # specific enough to match anywhere, like pgdevkit's `fetch_all`. Their SQL is Databricks SQL, so only the injection rules apply
705
+ # (`_call_trust`); a t-string or `unsafe_sql(...)` is handled like psycopg's t-string / `cast(LiteralString, ...)`.
706
+ _ARROWBRICKS_SQL_CALLEES = frozenset({"execute_streamed", "query_agent", "stream_query_json"})
707
+
708
+
709
+ def _attribute_root(node: ast.expr) -> str | None:
710
+ while isinstance(node, ast.Attribute):
711
+ node = node.value
712
+ return node.id if isinstance(node, ast.Name) else None
713
+
714
+
715
+ def _arrowbricks_sink_arg(call: ast.Call, trust: _Trust) -> ast.expr | None:
716
+ func = call.func
717
+ if isinstance(func, ast.Name):
718
+ # a bare name is the free function (also under an import alias) or, for the other two, the user's own helper
719
+ name = "stream_query_json" if func.id in trust.arrowbricks_stream_funcs else func.id
720
+ free_function = True
721
+ elif isinstance(func, ast.Attribute):
722
+ name = func.attr
723
+ free_function = _attribute_root(func.value) in trust.arrowbricks_modules # `arrowbricks.stream_query_json(client, sql)`
724
+ else:
725
+ return None
726
+ if name not in _ARROWBRICKS_SQL_CALLEES:
727
+ return None
728
+ index = 1 if name == "stream_query_json" and free_function else 0 # the free function takes the client first
729
+ if len(call.args) > index and not isinstance(call.args[index], ast.Starred):
730
+ return call.args[index]
731
+ return next((kw.value for kw in call.keywords if kw.arg == "sql"), None)
732
+
733
+
734
+ def _call_trust(call: ast.Call, trust: _Trust) -> tuple[ast.expr | None, _Trust]:
735
+ """The SQL argument of `call` (None if it isn't a SQL sink) and the trust to check it with: an arrowbricks call is
736
+ Databricks SQL without the postgres house style; a plain `.execute()` in a file that imports arrowbricks keeps the
737
+ postgres rules but may also be Databricks SQL (arrowbricks' `Cursor.execute`)."""
738
+ arrowbricks_arg = _arrowbricks_sink_arg(call, trust)
739
+ if arrowbricks_arg is not None:
740
+ return arrowbricks_arg, replace(trust, databricks=True, postgres_rules=False)
741
+ query_arg = _execute_query_arg(call, trust)
742
+ if query_arg is not None and trust.arrowbricks_file:
743
+ return query_arg, replace(trust, databricks=True)
744
+ return query_arg, trust
745
+
746
+
571
747
  class _ExecuteCallVisitor(ast.NodeVisitor):
572
748
  """Walks a module, tracking a stack of (function/class/module) local-variable scopes so a
573
749
  `cur.execute(query, ...)` call can resolve `query` back to every assignment it had in that
@@ -575,10 +751,11 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
575
751
  branch-shadowed unsafe assignment is still seen), then applies the SQL rules to every
576
752
  `.execute()`/`.executemany()` call found."""
577
753
 
578
- def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False) -> None:
754
+ def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False, arrowbricks_names: frozenset[str] = frozenset()) -> None:
579
755
  self.path = path
580
756
  self.trust = trust
581
757
  self.review = review
758
+ self._arrowbricks_names = arrowbricks_names # `*_sql` variables passed to an arrowbricks call (no postgres house style)
582
759
  self.findings: list[Finding] = []
583
760
  self._scopes: list[dict[str, list[ast.expr]]] = [{}]
584
761
  self._sqlglot_vars: set[str] = set() # names bound from a sqlglot expression chain
@@ -622,6 +799,8 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
622
799
  `.execute()` call in this file (e.g. it's passed to a helper) -- apply the complexity rule."""
623
800
  if not (_is_sql_named(name) and isinstance(value, ast.Constant) and isinstance(value.value, str)):
624
801
  return
802
+ if name in self._arrowbricks_names:
803
+ return
625
804
  parsed = parse_sql_text(value.value)
626
805
  if parsed is not None:
627
806
  self.findings.extend(_complexity_findings(parsed, value.value, self.path, lineno))
@@ -634,9 +813,9 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
634
813
 
635
814
  def visit_Call(self, node: ast.Call) -> None:
636
815
  self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
637
- query_arg = _execute_query_arg(node)
816
+ query_arg, trust = _call_trust(node, self.trust)
638
817
  if query_arg is not None:
639
- found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
818
+ found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, trust, self.review)
640
819
  if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id) and self._all_bare_strings(query_arg.id):
641
820
  found = [f for f in found if f.rule != "sql-inline-too-complex"] # already reported at the assignment
642
821
  self.findings.extend(found)
@@ -660,6 +839,16 @@ def check_sql_file(path: Path, source: str | None = None, *, review: bool = Fals
660
839
  tree = ast.parse(text, filename=str(path))
661
840
  except SyntaxError:
662
841
  return []
663
- visitor = _ExecuteCallVisitor(path, _file_trust(tree), review)
842
+ trust = _file_trust(tree)
843
+ arrowbricks_names: set[str] = set() # `*_sql` variables handed to an arrowbricks call ...
844
+ other_names: set[str] = set() # ... minus any name that is also handed to some other sink (it could be a psycopg query)
845
+ for call in ast.walk(tree):
846
+ if not isinstance(call, ast.Call):
847
+ continue
848
+ if isinstance(arg := _arrowbricks_sink_arg(call, trust), ast.Name):
849
+ arrowbricks_names.add(arg.id)
850
+ elif isinstance(arg := _execute_query_arg(call, trust), ast.Name):
851
+ other_names.add(arg.id)
852
+ visitor = _ExecuteCallVisitor(path, trust, review, frozenset(arrowbricks_names - other_names))
664
853
  visitor.visit(tree)
665
854
  return visitor.findings
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.34.0"
13
+ version = "0.36.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
@@ -5,7 +5,7 @@ description: >
5
5
  commands or repo-local scripts for: checking PR build/check status, creating
6
6
  a PR, creating a git worktree, committing and pushing files (with pre-flight
7
7
  checks), claiming an issue / work item (`bdt issue take`), a one-shot PR summary
8
- (`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg rules, pydantic placement, tooling; `bdt dead-code`:
8
+ (`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg/arrowbricks rules, pydantic placement, tooling; `bdt dead-code`:
9
9
  unreferenced .sql files and backend routes nothing calls), listing which env vars a repo defines (`bdt env get-keys`, names only). `bdt pr *` works against both Azure DevOps
10
10
  and GitHub — it auto-detects which one from the `origin` remote. Trigger
11
11
  whenever the user asks to check a build/PR status, create a PR, make a
@@ -123,7 +123,7 @@ vendor a git submodule under that path.
123
123
 
124
124
  ## Static checks
125
125
 
126
- - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
126
+ - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`/`execute`, `PostgresJsonResponse`, arrowbricks `execute_streamed`/`query_agent`/`stream_query_json`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
127
127
  - `uv run bdt dead-code` -- dead code a linter can't see, configured under `[tool.bdt.dead_code]` in pyproject.toml:
128
128
  `.sql` files no Python code loads (`sql_roots`) and FastAPI routes that neither non-generated frontend code nor a
129
129
  `url_for(...)` call uses (`[[tool.bdt.dead_code.apps]]`: app or openapi file, frontends, excludes, optional `baseline`;
@@ -151,6 +151,32 @@ def test_sql_injection_is_error_and_unverified_call_is_review(tmp_path: Path) ->
151
151
  assert [f.rule for f in result.reviews] == ["sql-unverified-call"]
152
152
 
153
153
 
154
+ def test_pgdevkit_execute_is_a_sql_sink_for_find_injection(tmp_path: Path) -> None:
155
+ (tmp_path / "db.py").write_text(
156
+ "from pgdevkit.db import execute\n\n"
157
+ 'async def a(x):\n await execute(f"update t set a = {x}")\n\n'
158
+ "async def b(x):\n await execute(build(x))\n"
159
+ )
160
+ (tmp_path / "other.py").write_text('from lib import execute\n\ndef a(x):\n execute(f"update t set a = {x}")\n')
161
+ result = _run(tmp_path)
162
+ assert [(f.path.name, f.rule) for f in result.errors] == [("db.py", "sql-fstring-injection")]
163
+ assert [(f.path.name, f.rule) for f in result.reviews] == [("db.py", "sql-unverified-call")]
164
+
165
+
166
+ def test_arrowbricks_calls_are_sql_sinks_for_find_injection(tmp_path: Path) -> None:
167
+ (tmp_path / "ab.py").write_text(
168
+ "from arrowbricks import unsafe_sql\n\n"
169
+ 'async def a(client, x):\n await client.query_agent(f"select * from t where id = {x}")\n\n'
170
+ "async def b(client, x):\n await client.query_agent(build(x))\n\n"
171
+ "async def c(client, request_sql):\n await client.query_agent(unsafe_sql(request_sql))\n\n"
172
+ 'async def d(client, x):\n await client.query_agent(unsafe_sql(f"select * from t where id = {x}"))\n\n'
173
+ 'async def e(client):\n await client.query_agent(unsafe_sql("select 1"))\n await client.query_agent(t"select {1}")\n'
174
+ )
175
+ result = _run(tmp_path)
176
+ assert [(f.rule, f.line) for f in result.errors] == [("sql-fstring-injection", 4), ("sql-fstring-injection", 13)]
177
+ assert [(f.rule, f.line) for f in result.reviews] == [("sql-unverified-call", 7), ("sql-unverified-cast", 10)]
178
+
179
+
154
180
  def test_tests_and_missing_paths(tmp_path: Path) -> None:
155
181
  (tmp_path / "tests").mkdir()
156
182
  (tmp_path / "tests" / "test_x.py").write_text("eval(x)\n")