bmsdna-devtools 0.32.0__tar.gz → 0.34.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.32.0 → bmsdna_devtools-0.34.0}/PKG-INFO +8 -3
  2. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/README.md +7 -2
  3. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/cli.py +2 -1
  4. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/find_injection.py +1 -0
  5. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_sql.py +123 -4
  6. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/pyproject.toml +1 -1
  7. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/skills/bmsdna-devtools/SKILL.md +1 -1
  8. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_sql.py +231 -0
  9. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/uv.lock +1 -1
  10. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/_api_dump.py +0 -0
  17. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/ado_issue.py +0 -0
  19. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/api_usage.py +0 -0
  20. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/app_service_logs.py +0 -0
  21. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/bdt_config.py +0 -0
  22. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/cli_tools.py +0 -0
  23. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/commit.py +0 -0
  24. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/dead_code.py +0 -0
  25. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/env_files.py +0 -0
  27. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/find_repo.py +0 -0
  28. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gh_issue.py +0 -0
  29. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gh_pr.py +0 -0
  30. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gitrepo.py +0 -0
  31. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/injection_frontend.py +0 -0
  32. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/injection_python.py +0 -0
  33. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/issue_do.py +0 -0
  34. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint.py +0 -0
  35. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_findings.py +0 -0
  36. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_models.py +0 -0
  37. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  38. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_tooling.py +0 -0
  39. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_typescript.py +0 -0
  40. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/logs.py +0 -0
  41. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_build.py +0 -0
  42. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_info.py +0 -0
  43. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  44. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_labels.py +0 -0
  45. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_markdown.py +0 -0
  46. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pull.py +0 -0
  47. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/testdb.py +0 -0
  48. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/translate.py +0 -0
  49. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/worktree.py +0 -0
  50. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/justfile +0 -0
  51. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/conftest.py +0 -0
  52. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_auth.py +0 -0
  53. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_issue.py +0 -0
  54. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_issue_flow.py +0 -0
  55. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_api_usage.py +0 -0
  56. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_app_service_logs.py +0 -0
  57. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_bdt_config.py +0 -0
  58. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_cleanup.py +0 -0
  59. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_commit.py +0 -0
  60. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_issue.py +0 -0
  61. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pr_create.py +0 -0
  62. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pr_id.py +0 -0
  63. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pull.py +0 -0
  64. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_tools.py +0 -0
  65. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_commit.py +0 -0
  66. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_env_config.py +0 -0
  67. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_env_files.py +0 -0
  68. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_find_injection.py +0 -0
  69. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_find_repo.py +0 -0
  70. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gh_issue.py +0 -0
  71. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gh_pr.py +0 -0
  72. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gitrepo.py +0 -0
  73. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_issue_do.py +0 -0
  74. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint.py +0 -0
  75. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_models.py +0 -0
  76. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_sql_files.py +0 -0
  77. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_tooling.py +0 -0
  78. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_typescript.py +0 -0
  79. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_build.py +0 -0
  80. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_build_flow.py +0 -0
  81. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_info.py +0 -0
  82. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_issue_link.py +0 -0
  83. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_labels.py +0 -0
  84. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_markdown.py +0 -0
  85. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pull.py +0 -0
  86. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_testdb.py +0 -0
  87. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_translate.py +0 -0
  88. {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.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.32.0
3
+ Version: 0.34.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
@@ -630,7 +630,8 @@ 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 whose SQL argument can be resolved to a
633
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
634
+ `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
634
635
  literal or f-string/concatenation/`%`-format expression is checked (an
635
636
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
636
637
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
@@ -647,6 +648,10 @@ it can't see through):
647
648
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
648
649
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
649
650
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
651
+ - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
652
+ sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
653
+ The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
654
+ Use `exp.column()`/`exp.to_identifier()` for names and `exp.Placeholder` + bound params for values.
650
655
  - **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
651
656
  - **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
652
657
  patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
@@ -830,7 +835,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
830
835
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
831
836
  code 0 unless `--strict`).
832
837
 
833
- - **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
838
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
834
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
835
840
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
836
841
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
@@ -616,7 +616,8 @@ 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 whose SQL argument can be resolved to a
619
+ Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
620
+ `PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
620
621
  literal or f-string/concatenation/`%`-format expression is checked (an
621
622
  unresolvable argument, e.g. a plain function parameter, is silently skipped --
622
623
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
@@ -633,6 +634,10 @@ it can't see through):
633
634
  with an f-string, `+` concatenation, the `%` operator, or `str.format()`
634
635
  instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
635
636
  usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
637
+ - **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
638
+ sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
639
+ The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
640
+ Use `exp.column()`/`exp.to_identifier()` for names and `exp.Placeholder` + bound params for values.
636
641
  - **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
637
642
  - **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
638
643
  patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
@@ -816,7 +821,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
816
821
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
817
822
  code 0 unless `--strict`).
818
823
 
819
- - **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
824
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
820
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
821
826
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
822
827
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
@@ -1092,7 +1092,8 @@ def lint(
1092
1092
  "[tool.bdt.lint] skip_tooling_check = true in pyproject.toml.",
1093
1093
  ),
1094
1094
  ) -> None:
1095
- """Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call
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
1097
  (must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
1097
1098
  query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
1098
1099
  directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
@@ -39,6 +39,7 @@ SQL_INJECTION_RULES = frozenset(
39
39
  "sql-percent-format-injection",
40
40
  "sql-concat-injection",
41
41
  "sql-format-injection",
42
+ "sql-sqlglot-string-injection",
42
43
  }
43
44
  )
44
45
  _IGNORE_RE = re.compile(r"bdt-lint:\s*ignore\s+([\w-]+(?:\s*,\s*[\w-]+)*)")
@@ -1,5 +1,6 @@
1
1
  """AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
2
- every `.execute()`/`.executemany()` call is classified by how its SQL argument was
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
3
4
  built, then checked against the `postgres-best-practices` skill's rules
4
5
  (../../skills/postgres-best-practices in a checkout of bmsuisse/skills).
5
6
 
@@ -25,7 +26,8 @@ from __future__ import annotations
25
26
  import ast
26
27
  import re
27
28
  import sys
28
- from dataclasses import dataclass
29
+ from collections.abc import Mapping
30
+ from dataclasses import dataclass, field
29
31
  from pathlib import Path
30
32
 
31
33
  from .lint_findings import Finding
@@ -124,6 +126,7 @@ class _Trust:
124
126
 
125
127
  sqlglot_names: frozenset[str] = frozenset()
126
128
  literal_funcs: frozenset[str] = frozenset()
129
+ sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
127
130
 
128
131
 
129
132
  _NO_TRUST = _Trust()
@@ -185,6 +188,7 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
185
188
  def _file_trust(tree: ast.AST) -> _Trust:
186
189
  literal_funcs: set[str] = set()
187
190
  sqlglot_names: set[str] = set()
191
+ sqlglot_aliases: dict[str, str] = {}
188
192
  for node in ast.walk(tree):
189
193
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
190
194
  literal_funcs.add(node.name)
@@ -192,7 +196,8 @@ def _file_trust(tree: ast.AST) -> _Trust:
192
196
  sqlglot_names.update((a.asname or a.name).split(".")[0] for a in node.names if a.name.split(".")[0] == "sqlglot")
193
197
  elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
194
198
  sqlglot_names.update(a.asname or a.name for a in node.names)
195
- return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs))
199
+ 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)
196
201
 
197
202
 
198
203
  def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
@@ -450,14 +455,119 @@ def _is_sql_named(name: str) -> bool:
450
455
  return lowered == "sql" or lowered.endswith("_sql")
451
456
 
452
457
 
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
462
+ # `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
463
+ # only gets checked once that call site moves to pgdevkit.
464
+ _PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "PostgresJsonResponse"})
465
+
466
+
453
467
  def _execute_query_arg(call: ast.Call) -> ast.expr | None:
454
- if not (isinstance(call.func, ast.Attribute) and call.func.attr in ("execute", "executemany")):
468
+ func = call.func
469
+ 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):
455
474
  return None
456
475
  if call.args:
457
476
  return call.args[0]
458
477
  return next((kw.value for kw in call.keywords if kw.arg == "query"), None)
459
478
 
460
479
 
480
+ # sqlglot's builders parse plain strings as SQL (`select(f"a, {x}")`, `.where(f"id = {x}")`), so text
481
+ # interpolated into one is as dangerous as text passed straight to `.execute()` -- even though the
482
+ # result is a sqlglot expression the rules above otherwise trust. Value arguments (`exp.column`,
483
+ # `exp.Placeholder`, `exp.convert`, `exp.to_identifier`, ...) quote/bind safely and aren't listed.
484
+ _SQLGLOT_STRING_BUILDERS = frozenset(
485
+ {"select", "from_", "where", "join", "order_by", "group_by", "having", "with_", "parse_one", "parse", "condition", "and_", "or_", "maybe_parse"}
486
+ )
487
+ # Keyword arguments of those builders that are options, not SQL text.
488
+ _SQLGLOT_NON_SQL_KEYWORDS = frozenset(
489
+ {"dialect", "read", "write", "copy", "append", "alias", "join_alias", "join_type", "into", "opts", "dialect_name", "table"}
490
+ )
491
+
492
+
493
+ def _concat_leaves(node: ast.expr) -> list[ast.expr]:
494
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
495
+ return [*_concat_leaves(node.left), *_concat_leaves(node.right)]
496
+ return [node]
497
+
498
+
499
+ def _is_str_constant(node: ast.expr) -> bool:
500
+ return isinstance(node, ast.Constant) and isinstance(node.value, str)
501
+
502
+
503
+ def _interpolation_kind(node: ast.expr) -> str | None:
504
+ """How `node` interpolates non-literal text into a string, or None if it doesn't. An
505
+ interpolated *constant* (`f"{1}"`, `f"{'x'}"`) can't carry user text, so it doesn't count."""
506
+ if isinstance(node, ast.JoinedStr):
507
+ dynamic = any(isinstance(v, ast.FormattedValue) and not isinstance(v.value, ast.Constant) for v in node.values)
508
+ return "an f-string" if dynamic else None
509
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
510
+ leaves = _concat_leaves(node)
511
+ if any(_is_str_constant(x) for x in leaves) and not all(isinstance(x, ast.Constant) for x in leaves):
512
+ return "string concatenation"
513
+ return None
514
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
515
+ return "the `%` string-formatting operator" if _is_str_constant(node.left) else None
516
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
517
+ if node.func.attr == "format" and _is_str_constant(node.func.value):
518
+ return "`str.format()`"
519
+ if node.func.attr == "join" and _is_str_constant(node.func.value) and node.args:
520
+ # `" and ".join(f"{c} = 1" for c in cols)`: the element is what carries the text
521
+ elements = _elements(node.args[0])
522
+ return next((k for e in elements if (k := _interpolation_kind(e)) is not None), None)
523
+ return None
524
+
525
+
526
+ def _elements(node: ast.expr) -> list[ast.expr]:
527
+ """The element expression(s) of a literal list/tuple or a comprehension/generator."""
528
+ if isinstance(node, (ast.ListComp, ast.GeneratorExp, ast.SetComp)):
529
+ return [node.elt]
530
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
531
+ return list(node.elts)
532
+ return []
533
+
534
+
535
+ def _sqlglot_builder_findings(
536
+ call: ast.Call, path: Path, trust: _Trust, lookup, sqlglot_vars: set[str]
537
+ ) -> list[Finding]:
538
+ if not trust.sqlglot_names:
539
+ return []
540
+ func = call.func
541
+ name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else None
542
+ root = _call_root_name(func)
543
+ if isinstance(func, ast.Name):
544
+ name = trust.sqlglot_aliases.get(func.id, func.id) # `from sqlglot import select as s`
545
+ # Only chains rooted at a sqlglot name or at a variable holding a sqlglot expression: `.where()`/
546
+ # `.group_by()` on a polars/Django/SQLAlchemy object in the same file is none of our business.
547
+ if name not in _SQLGLOT_STRING_BUILDERS or not (root in trust.sqlglot_names or root in sqlglot_vars):
548
+ return []
549
+ arguments: list[ast.expr] = []
550
+ for arg in call.args:
551
+ arguments.append(arg.value if isinstance(arg, ast.Starred) else arg)
552
+ arguments.extend(kw.value for kw in call.keywords if kw.arg not in _SQLGLOT_NON_SQL_KEYWORDS)
553
+ for arg in arguments:
554
+ candidates = [c for top in [arg, *_elements(arg)] for c in _resolve_candidates(top, lookup)]
555
+ for candidate in candidates:
556
+ kind = _interpolation_kind(candidate)
557
+ if kind is not None:
558
+ return [
559
+ Finding(
560
+ path,
561
+ arg.lineno,
562
+ "sql-sqlglot-string-injection",
563
+ f"`{name}(...)` parses its string argument as SQL, and it is built with {kind} -- injection risk. "
564
+ "Build it from sqlglot nodes instead (`exp.column()`/`exp.to_identifier()` for names, `exp.Placeholder` "
565
+ "+ bound params for values).",
566
+ )
567
+ ]
568
+ return []
569
+
570
+
461
571
  class _ExecuteCallVisitor(ast.NodeVisitor):
462
572
  """Walks a module, tracking a stack of (function/class/module) local-variable scopes so a
463
573
  `cur.execute(query, ...)` call can resolve `query` back to every assignment it had in that
@@ -471,6 +581,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
471
581
  self.review = review
472
582
  self.findings: list[Finding] = []
473
583
  self._scopes: list[dict[str, list[ast.expr]]] = [{}]
584
+ self._sqlglot_vars: set[str] = set() # names bound from a sqlglot expression chain
474
585
 
475
586
  def _lookup(self, name: str) -> list[ast.expr]:
476
587
  for scope in reversed(self._scopes):
@@ -487,14 +598,21 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
487
598
  visit_AsyncFunctionDef = _visit_scoped
488
599
  visit_ClassDef = _visit_scoped
489
600
 
601
+ def _track_sqlglot_var(self, name: str, value: ast.expr) -> None:
602
+ if isinstance(value, ast.Call) and (root := _call_root_name(value.func)) is not None:
603
+ if root in self.trust.sqlglot_names or root in self._sqlglot_vars:
604
+ self._sqlglot_vars.add(name)
605
+
490
606
  def visit_Assign(self, node: ast.Assign) -> None:
491
607
  if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
608
+ self._track_sqlglot_var(node.targets[0].id, node.value)
492
609
  self._scopes[-1].setdefault(node.targets[0].id, []).append(node.value)
493
610
  self._check_sql_named_literal(node.targets[0].id, node.value, node.lineno)
494
611
  self.generic_visit(node)
495
612
 
496
613
  def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
497
614
  if isinstance(node.target, ast.Name) and node.value is not None:
615
+ self._track_sqlglot_var(node.target.id, node.value)
498
616
  self._scopes[-1].setdefault(node.target.id, []).append(node.value)
499
617
  self._check_sql_named_literal(node.target.id, node.value, node.lineno)
500
618
  self.generic_visit(node)
@@ -515,6 +633,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
515
633
  return bool(values) and all(isinstance(v, ast.Constant) and isinstance(v.value, str) for v in values)
516
634
 
517
635
  def visit_Call(self, node: ast.Call) -> None:
636
+ self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
518
637
  query_arg = _execute_query_arg(node)
519
638
  if query_arg is not None:
520
639
  found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.32.0"
13
+ version = "0.34.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
@@ -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, pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
126
+ - `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`, `PostgresJsonResponse`), 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`;
@@ -377,3 +377,234 @@ async def f(cur):
377
377
  await cur.execute(q_sql)
378
378
  """
379
379
  assert "sql-inline-too-complex" in _rules(_findings(source, tmp_path / "a.py"))
380
+
381
+
382
+ def test_fetch_all_with_fstring_is_flagged_like_execute(tmp_path: Path) -> None:
383
+ source = '''
384
+ from pgdevkit.db import fetch_all
385
+
386
+ async def f(table):
387
+ return await fetch_all(f"SELECT id FROM dim.{table} WHERE active")
388
+ '''
389
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
390
+
391
+
392
+ def test_fetch_helpers_flag_concat_format_and_percent(tmp_path: Path) -> None:
393
+ source = '''
394
+ async def f(db, value):
395
+ await fetch_one("select * from t where id = " + str(value))
396
+ await fetch_scalar("select count(*) from t where id = %s" % value)
397
+ await db.fetch_all("select * from t where id = {}".format(value))
398
+ '''
399
+ assert _rules(_findings(source, tmp_path / "a.py")) == {
400
+ "sql-concat-injection",
401
+ "sql-percent-format-injection",
402
+ "sql-format-injection",
403
+ }
404
+
405
+
406
+ def test_fetch_all_accepts_literal_load_sql_sqlglot_and_template(tmp_path: Path) -> None:
407
+ source = '''
408
+ from sqlglot import select
409
+ from pgdevkit.db import fetch_all
410
+
411
+ async def f(sql_loader, model, user_id):
412
+ await fetch_all(sql_loader.load_sql("users", "list_active"), {"limit": 5}, model=model)
413
+ await fetch_all(select("id").from_("t").sql(dialect="postgres"))
414
+ await fetch_all(t"select id from t where user_id = {user_id}")
415
+ await fetch_all("select id from t where user_id = %(id)s", {"id": user_id}, model=model)
416
+ '''
417
+ assert _findings(source, tmp_path / "a.py") == []
418
+
419
+
420
+ def test_fetch_all_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
421
+ source = '''
422
+ async def f(model):
423
+ await fetch_all("select a.id from a join b on b.a_id = a.id", model=model)
424
+ await fetch_all("select id from t where x = %s", (1,))
425
+ '''
426
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
427
+
428
+
429
+ def test_unrelated_fetch_all_name_with_non_sql_text_is_not_flagged(tmp_path: Path) -> None:
430
+ source = '''
431
+ async def f(client, name):
432
+ return await client.fetch_all(f"users/{name}")
433
+ '''
434
+ assert _findings(source, tmp_path / "a.py") == []
435
+
436
+
437
+ def test_sqlglot_builder_with_interpolated_string_is_flagged(tmp_path: Path) -> None:
438
+ source = '''
439
+ from sqlglot import select
440
+ import sqlglot
441
+
442
+ async def f(con, user_filter, col):
443
+ q1 = select("id").from_("t").where(f"name = {user_filter}")
444
+ q2 = sqlglot.parse_one("select " + col + " from t")
445
+ q3 = select("id").from_("t").order_by("{} desc".format(col))
446
+ await fetch_all(q1.sql(dialect="postgres"))
447
+ '''
448
+ findings = _findings(source, tmp_path / "a.py")
449
+ assert _rules(findings) == {"sql-sqlglot-string-injection"}
450
+ assert len(findings) == 3
451
+
452
+
453
+ def test_sqlglot_builders_with_literals_and_nodes_are_clean(tmp_path: Path) -> None:
454
+ source = '''
455
+ from sqlglot import exp, select
456
+
457
+ async def f(col):
458
+ q = select("id", "name").from_("dim.customer").where("active = true")
459
+ q = q.where(exp.column(col).eq(exp.Placeholder(this="id")))
460
+ q = q.order_by(exp.to_identifier(col).sql())
461
+ return q
462
+ '''
463
+ assert _findings(source, tmp_path / "a.py") == []
464
+
465
+
466
+ def test_sqlglot_rule_ignores_files_that_do_not_use_sqlglot(tmp_path: Path) -> None:
467
+ source = '''
468
+ def f(qs, name):
469
+ return qs.where(f"name = {name}").select(f"x{name}")
470
+ '''
471
+ assert _findings(source, tmp_path / "a.py") == []
472
+
473
+
474
+ def test_sqlglot_method_on_a_variable_is_flagged_but_generic_join_is_not(tmp_path: Path) -> None:
475
+ source = '''
476
+ from sqlglot import select
477
+
478
+ def f(user_filter, parts):
479
+ q = select("id").from_("t")
480
+ q = q.where(f"name = {user_filter}")
481
+ label = ", ".join(f"{p}!" for p in parts) + "".join(f"{p}" for p in parts)
482
+ return q, label
483
+ '''
484
+ findings = _findings(source, tmp_path / "a.py")
485
+ assert [f.rule for f in findings] == ["sql-sqlglot-string-injection"]
486
+
487
+
488
+ def test_sqlglot_rule_gaps_found_in_review(tmp_path: Path) -> None:
489
+ source = '''
490
+ from sqlglot import select as s, parse_one as p
491
+ import sqlglot
492
+
493
+ def f(x, y, cols, xs, m):
494
+ w = f"a={x}"
495
+ q = s("a").from_("t").where("a = " + x + y) # 3-part concatenation
496
+ q = q.where(w) # through a variable
497
+ p(f"select {x}") # aliased import
498
+ s(*[f"{c} as {a}" for c, a in m]) # starred comprehension
499
+ q.where(" and ".join(f"{c}=1" for c in cols)) # join of interpolated elements
500
+ return q
501
+ '''
502
+ findings = _findings(source, tmp_path / "a.py")
503
+ assert [f.rule for f in findings] == ["sql-sqlglot-string-injection"] * 5
504
+ assert [f.line for f in findings] == [7, 8, 9, 10, 11]
505
+
506
+
507
+ def test_sqlglot_rule_false_positive_guards(tmp_path: Path) -> None:
508
+ source = '''
509
+ import sqlglot
510
+ from sqlglot import select
511
+
512
+ class A:
513
+ def go(self, c):
514
+ return self.where(f"{c}")
515
+
516
+ def f(df, Model, session, x, d, i):
517
+ df.group_by(f"{x}_id")
518
+ Model.objects.order_by(f"-{x}")
519
+ session.query(A).group_by(f"{x}").having(f"{x}")
520
+ sqlglot.parse_one("select 1", dialect=f"{d}")
521
+ select("a").from_("t", alias=f"t{i}")
522
+ select("a").where(f"{1}")
523
+ select("a").where(f"a={'x'}")
524
+ select("a").where(f"b = {i}", dialect=d) # the one real finding: positional f-string with a name
525
+ '''
526
+ findings = _findings(source, tmp_path / "a.py")
527
+ assert [f.line for f in findings] == [17]
528
+
529
+
530
+ def test_sqlglot_finding_line_is_the_offending_argument_in_a_multiline_chain(tmp_path: Path) -> None:
531
+ source = '''
532
+ from sqlglot import select
533
+
534
+ def f(y):
535
+ return (
536
+ select("a")
537
+ .from_("t")
538
+ .where(f"a={y}")
539
+ )
540
+ '''
541
+ assert [f.line for f in _findings(source, tmp_path / "a.py")] == [8]
542
+
543
+
544
+ def test_postgres_json_response_with_fstring_is_flagged_like_execute(tmp_path: Path) -> None:
545
+ source = '''
546
+ from pgdevkit.fastapi import PostgresJsonResponse
547
+
548
+ async def f(table):
549
+ return PostgresJsonResponse(f"SELECT id FROM dim.{table} WHERE active")
550
+ '''
551
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
552
+
553
+
554
+ def test_postgres_json_response_flags_every_interpolation_form_and_call_shape(tmp_path: Path) -> None:
555
+ source = '''
556
+ import pgdevkit.fastapi as pgf
557
+
558
+ def f(value, table):
559
+ PostgresJsonResponse("select * from t where id = " + str(value))
560
+ PostgresJsonResponse("select * from t where id = %s" % value)
561
+ pgf.PostgresJsonResponse("select * from t where id = {}".format(value))
562
+ PostgresJsonResponse(query=f"select id from {table}", pool=None)
563
+ '''
564
+ findings = _findings(source, tmp_path / "a.py")
565
+ assert _rules(findings) == {
566
+ "sql-concat-injection",
567
+ "sql-percent-format-injection",
568
+ "sql-format-injection",
569
+ "sql-fstring-injection",
570
+ }
571
+ assert len(findings) == 4
572
+
573
+
574
+ def test_postgres_json_response_follows_a_variable_to_its_unsafe_assignment(tmp_path: Path) -> None:
575
+ source = '''
576
+ def f(table):
577
+ query = f"select id from dim.{table} where active"
578
+ return PostgresJsonResponse(query, {"x": 1})
579
+ '''
580
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
581
+
582
+
583
+ def test_postgres_json_response_accepts_literal_load_sql_and_named_params(tmp_path: Path) -> None:
584
+ source = '''
585
+ def f(sql_loader, lng):
586
+ PostgresJsonResponse(sql_loader.load_sql("articles", "list_articles"), {"lng": lng})
587
+ PostgresJsonResponse("select id, name from articles where lng = %(lng)s", {"lng": lng})
588
+ PostgresJsonResponse("select 1 as a", query_produces_json=False, batch_size=10, statement_timeout=5)
589
+ PostgresJsonResponse(t"select id from articles where lng = {lng}")
590
+ '''
591
+ assert _findings(source, tmp_path / "a.py") == []
592
+
593
+
594
+ def test_postgres_json_response_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
595
+ source = '''
596
+ def f():
597
+ PostgresJsonResponse("select a.id from a join b on b.a_id = a.id")
598
+ PostgresJsonResponse("select id from t where x = %s", (1,))
599
+ '''
600
+ assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
601
+
602
+
603
+ def test_legacy_ccmt_postgres_json_response_with_connection_first_is_not_flagged(tmp_path: Path) -> None:
604
+ # CCMT2's own pre-pgdevkit class takes `("postgres", sql, ...)`: its first argument is no SQL, so it stays
605
+ # unchecked (silently skipped, never a false positive) until that call site moves to pgdevkit's class.
606
+ source = '''
607
+ def f(table):
608
+ return PostgresJsonResponse("postgres", f"select id from dim.{table}", parameters={})
609
+ '''
610
+ assert _findings(source, tmp_path / "a.py") == []
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.32.0"
25
+ version = "0.34.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },