bmsdna-devtools 0.26.0__tar.gz → 0.27.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 (74) hide show
  1. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/PKG-INFO +6 -2
  2. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/README.md +5 -1
  3. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/cli.py +8 -1
  4. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/find_injection.py +89 -31
  5. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_find_injection.py +62 -0
  7. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/uv.lock +1 -1
  8. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/auto-release.yml +0 -0
  9. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/python-publish.yml +0 -0
  10. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/python-test.yml +0 -0
  11. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.gitignore +0 -0
  12. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.python-version +0 -0
  13. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/__init__.py +0 -0
  14. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/ado_auth.py +0 -0
  15. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/ado_issue.py +0 -0
  16. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/app_service_logs.py +0 -0
  17. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/bdt_config.py +0 -0
  18. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/cli_tools.py +0 -0
  19. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/commit.py +0 -0
  20. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/env_config.py +0 -0
  21. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/find_repo.py +0 -0
  22. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gh_issue.py +0 -0
  23. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gh_pr.py +0 -0
  24. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gitrepo.py +0 -0
  25. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/injection_frontend.py +0 -0
  26. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/injection_python.py +0 -0
  27. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint.py +0 -0
  28. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_findings.py +0 -0
  29. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_models.py +0 -0
  30. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_sql.py +0 -0
  31. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_tooling.py +0 -0
  32. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_typescript.py +0 -0
  33. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/logs.py +0 -0
  34. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_build.py +0 -0
  35. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  36. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_labels.py +0 -0
  37. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_markdown.py +0 -0
  38. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pull.py +0 -0
  39. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/testdb.py +0 -0
  40. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/worktree.py +0 -0
  41. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/justfile +0 -0
  42. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  43. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/conftest.py +0 -0
  44. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_auth.py +0 -0
  45. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_issue.py +0 -0
  46. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_issue_flow.py +0 -0
  47. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_app_service_logs.py +0 -0
  48. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_bdt_config.py +0 -0
  49. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_cleanup.py +0 -0
  50. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_commit.py +0 -0
  51. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_issue.py +0 -0
  52. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pr_create.py +0 -0
  53. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pr_id.py +0 -0
  54. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pull.py +0 -0
  55. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_tools.py +0 -0
  56. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_commit.py +0 -0
  57. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_env_config.py +0 -0
  58. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_find_repo.py +0 -0
  59. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gh_issue.py +0 -0
  60. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gh_pr.py +0 -0
  61. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gitrepo.py +0 -0
  62. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint.py +0 -0
  63. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_models.py +0 -0
  64. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_sql.py +0 -0
  65. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_tooling.py +0 -0
  66. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_typescript.py +0 -0
  67. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_build.py +0 -0
  68. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_build_flow.py +0 -0
  69. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_issue_link.py +0 -0
  70. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_labels.py +0 -0
  71. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_markdown.py +0 -0
  72. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pull.py +0 -0
  73. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_testdb.py +0 -0
  74. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.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.26.0
3
+ Version: 0.27.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
@@ -607,7 +607,11 @@ code 0 unless `--strict`).
607
607
  `staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
608
608
  `'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
609
609
 
610
- Test files and generated code are skipped. Silence a confirmed-safe finding with
610
+ Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
611
+ inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
612
+ Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
613
+ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
614
+ Identical findings on the same line are reported once. Silence a confirmed-safe finding with
611
615
  `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
612
616
 
613
617
  ## Releasing
@@ -594,7 +594,11 @@ code 0 unless `--strict`).
594
594
  `staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
595
595
  `'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
596
596
 
597
- Test files and generated code are skipped. Silence a confirmed-safe finding with
597
+ Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
598
+ inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
599
+ Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
600
+ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
601
+ Identical findings on the same line are reported once. Silence a confirmed-safe finding with
598
602
  `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
599
603
 
600
604
  ## Releasing
@@ -933,13 +933,20 @@ def find_injection_cmd(
933
933
  diff: bool = typer.Option(False, "--diff", help="Scan only files changed on this branch vs --base, plus uncommitted and untracked files."),
934
934
  base: str = typer.Option(None, "--base", help="Branch/ref to diff against with --diff (default: origin's default branch)."),
935
935
  strict: bool = typer.Option(False, "--strict", help="Also exit 1 when there are items to review (default: only definite errors fail)."),
936
+ exclude: list[str] = typer.Option(
937
+ None,
938
+ "--exclude",
939
+ help="Directory name (any depth), root-relative path, or glob to skip (repeatable), e.g. --exclude vendor --exclude 'assets/**/*.js'. "
940
+ "Also configurable via [tool.bdt.lint] exclude_dirs in pyproject.toml.",
941
+ ),
942
+ no_gitignore: bool = typer.Option(False, "--no-gitignore", help="Also scan files ignored by .gitignore (default: they are skipped)."),
936
943
  ) -> None:
937
944
  """Find injection risks (bmsuisse/devtools#54) in backend and frontend code: SQL built from f-strings/
938
945
  concatenation or from an unverifiable function call, eval/exec/shell/unsafe deserialization in Python,
939
946
  innerHTML/dangerouslySetInnerHTML/eval/new Function/un-sandboxed iframes in the frontend, and a missing
940
947
  or weakened Content-Security-Policy. Definite problems are errors; the rest are listed for a human/AI to verify.
941
948
  """
942
- result = find_injection_mod.run(paths or [], diff=diff, base=base)
949
+ result = find_injection_mod.run(paths or [], diff=diff, base=base, exclude=exclude or [], respect_gitignore=not no_gitignore)
943
950
  raise typer.Exit(find_injection_mod.print_report(result, strict=strict))
944
951
 
945
952
 
@@ -14,11 +14,13 @@ from __future__ import annotations
14
14
 
15
15
  import os
16
16
  import re
17
+ from fnmatch import fnmatch
17
18
  import subprocess
18
19
  import sys
19
20
  from dataclasses import dataclass
20
21
  from pathlib import Path
21
22
 
23
+ from .bdt_config import load_bdt_table
22
24
  from .injection_frontend import (
23
25
  FRONTEND_SUFFIXES,
24
26
  check_frontend_file,
@@ -165,21 +167,79 @@ def _scan_python(path: Path) -> list[Finding]:
165
167
  return sql + check_python_sinks(path)
166
168
 
167
169
 
168
- def _walk_repo_text_files(root: Path, exclude_dirs: frozenset[str]):
170
+ _GLOB_CHARS = frozenset("*?[")
171
+
172
+
173
+ class _ScanFilter:
174
+ """Which files are in scope: not under an excluded directory name, not matching an `--exclude`
175
+ path/glob (relative to the repo root), and -- in a git repo, unless disabled -- not gitignored
176
+ (tracked files always count, like git itself)."""
177
+
178
+ def __init__(self, root: Path, exclude: list[str], respect_gitignore: bool) -> None:
179
+ self.root = root
180
+ self.dir_names = _DEFAULT_EXCLUDE_DIR_NAMES | {".next", "coverage", "storybook-static"}
181
+ self._patterns: list[str] = []
182
+ for entry in exclude:
183
+ entry = entry.replace("\\", "/").strip().strip("/")
184
+ if entry.startswith("./"):
185
+ entry = entry[2:]
186
+ if not entry:
187
+ continue
188
+ if "/" not in entry and not _GLOB_CHARS & set(entry):
189
+ self.dir_names = self.dir_names | {entry}
190
+ else:
191
+ self._patterns.append(entry)
192
+ self._visible = _git_visible_files(root) if respect_gitignore else None
193
+
194
+ def allows(self, path: Path) -> bool:
195
+ resolved = path.resolve()
196
+ try:
197
+ rel = resolved.relative_to(self.root)
198
+ except ValueError:
199
+ return True
200
+ if any(part in self.dir_names for part in rel.parts[:-1]):
201
+ return False
202
+ posix = rel.as_posix()
203
+ if any(fnmatch(posix, pat) or fnmatch(posix, pat + "/*") for pat in self._patterns):
204
+ return False
205
+ return self._visible is None or resolved in self._visible
206
+
207
+
208
+ def _git_visible_files(root: Path) -> set[Path] | None:
209
+ """Tracked plus untracked-but-not-ignored files under `root`, or None outside a git repo."""
210
+ try:
211
+ out = subprocess.run(
212
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
213
+ cwd=root,
214
+ capture_output=True,
215
+ encoding="utf-8",
216
+ )
217
+ except FileNotFoundError:
218
+ return None
219
+ if out.returncode != 0:
220
+ return None
221
+ return {(root / name).resolve() for name in out.stdout.split("\0") if name}
222
+
223
+
224
+ def _is_minified(path: Path) -> bool:
225
+ return path.name.endswith(".min.js")
226
+
227
+
228
+ def _walk_repo_text_files(root: Path, scope: _ScanFilter):
169
229
  for dirpath, dirnames, filenames in os.walk(root):
170
- dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
230
+ dirnames[:] = [d for d in dirnames if d not in scope.dir_names]
171
231
  for name in filenames:
172
232
  if name in _SKIP_NAMES or name.endswith(".min.js") or not (name in _CONFIG_NAMES or name.endswith(_CONFIG_SUFFIXES)):
173
233
  continue
174
234
  candidate = Path(dirpath, name)
175
235
  try:
176
- if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES:
236
+ if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES and scope.allows(candidate):
177
237
  yield candidate
178
238
  except OSError:
179
239
  continue
180
240
 
181
241
 
182
- def check_csp(root: Path, scanned: list[Path], exclude_dirs: frozenset[str], only: set[Path] | None = None) -> list[Finding]:
242
+ def check_csp(root: Path, scanned: list[Path], scope: _ScanFilter, only: set[Path] | None = None) -> list[Finding]:
183
243
  """Warns when the project serves a web UI/API (frontend files scanned, or a Python web framework
184
244
  imported) but no file anywhere under `root` mentions a Content-Security-Policy. Always searches the
185
245
  whole repo -- a diff that doesn't touch the header config mustn't look like "no CSP configured".
@@ -198,7 +258,7 @@ def check_csp(root: Path, scanned: list[Path], exclude_dirs: frozenset[str], onl
198
258
  return []
199
259
  weakened: list[Finding] = []
200
260
  found = False
201
- for candidate in _walk_repo_text_files(root, exclude_dirs):
261
+ for candidate in _walk_repo_text_files(root, scope):
202
262
  try:
203
263
  text = candidate.read_text(encoding="utf-8")
204
264
  except OSError, UnicodeDecodeError:
@@ -227,36 +287,31 @@ def run(
227
287
  root: Path | None = None,
228
288
  diff: bool = False,
229
289
  base: str | None = None,
290
+ exclude: list[str] | None = None,
291
+ respect_gitignore: bool = True,
230
292
  ) -> InjectionResult:
293
+ """`exclude`: directory names or root-relative paths/globs to skip, on top of `[tool.bdt.lint] exclude_dirs`
294
+ in pyproject.toml and the built-in list (node_modules, .venv, dist, ...)."""
231
295
  root = (root or Path.cwd()).resolve()
232
- exclude_dirs = _DEFAULT_EXCLUDE_DIR_NAMES | {
233
- ".next",
234
- "coverage",
235
- "storybook-static",
236
- }
296
+ configured = [str(d) for d in load_bdt_table("lint", root).get("exclude_dirs", []) or []]
297
+ scope = _ScanFilter(root, configured + list(exclude or []), respect_gitignore)
237
298
  findings: list[Finding] = []
238
299
  changed: list[Path] = []
239
300
 
240
301
  if diff:
241
- changed = [p for p in diff_files(root, base) if not _in_excluded_dir(p, root, exclude_dirs)]
302
+ changed = [p for p in diff_files(root, base) if scope.allows(p)]
242
303
  py_files = [p for p in changed if p.suffix == ".py"]
243
304
  fe_files = [p for p in changed if p.suffix.lower() in FRONTEND_SUFFIXES]
244
305
  else:
245
306
  targets = [Path(p).resolve() for p in paths] if paths else [root]
246
- py_files, missing = _iter_files(targets, exclude_dirs, (".py",))
247
- fe_files, _ = _iter_files(targets, exclude_dirs, FRONTEND_SUFFIXES)
248
- findings.extend(
249
- Finding(
250
- p,
251
- 0,
252
- "path-not-found",
253
- f"'{p}' doesn't exist -- nothing was scanned for it.",
254
- )
255
- for p in missing
256
- )
307
+ py_files, missing = _iter_files(targets, scope.dir_names, (".py",))
308
+ fe_files, _ = _iter_files(targets, scope.dir_names, FRONTEND_SUFFIXES)
309
+ py_files = [p for p in py_files if scope.allows(p)]
310
+ fe_files = [p for p in fe_files if scope.allows(p)]
311
+ findings.extend(Finding(p, 0, "path-not-found", f"'{p}' doesn't exist -- nothing was scanned for it.") for p in missing)
257
312
 
258
313
  py_files = [p for p in py_files if not _is_test_file(p, root)]
259
- fe_files = [p for p in fe_files if not _is_test_file(p, root)]
314
+ fe_files = [p for p in fe_files if not _is_test_file(p, root) and not _is_minified(p)]
260
315
 
261
316
  if py_files:
262
317
  require_sqlglot()
@@ -265,16 +320,19 @@ def run(
265
320
  for path in fe_files:
266
321
  findings.extend(check_frontend_file(path))
267
322
 
268
- findings.extend(check_csp(root, py_files + fe_files, exclude_dirs, {p.resolve() for p in changed} if diff else None))
269
- return InjectionResult(findings=_filter_ignored(findings))
323
+ findings.extend(check_csp(root, py_files + fe_files, scope, {p.resolve() for p in changed} if diff else None))
324
+ return InjectionResult(findings=_dedupe(_filter_ignored(findings)))
270
325
 
271
326
 
272
- def _in_excluded_dir(path: Path, root: Path, exclude_dirs: frozenset[str]) -> bool:
273
- try:
274
- parts = path.relative_to(root).parts[:-1]
275
- except ValueError:
276
- parts = path.parts[:-1]
277
- return any(part in exclude_dirs for part in parts)
327
+ def _dedupe(findings: list[Finding]) -> list[Finding]:
328
+ seen: set[tuple[Path, int, str]] = set()
329
+ unique: list[Finding] = []
330
+ for f in findings:
331
+ key = (f.path, f.line, f.rule)
332
+ if key not in seen:
333
+ seen.add(key)
334
+ unique.append(f)
335
+ return unique
278
336
 
279
337
 
280
338
  def _is_test_file(path: Path, root: Path) -> bool:
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.26.0"
13
+ version = "0.27.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
@@ -236,3 +236,65 @@ def test_diff_mode_from_subdirectory_finds_untracked_files(tmp_path: Path) -> No
236
236
  (tmp_path / "backend" / "café.py").write_text("eval(y)\n")
237
237
  result = find_injection.run([], root=tmp_path / "backend", diff=True, base="main")
238
238
  assert [f.path.name for f in result.findings] == ["café.py"]
239
+
240
+
241
+ def _rules_by_file(result) -> set[str]:
242
+ return {f.path.name for f in result.findings}
243
+
244
+
245
+ def test_exclude_dir_name_path_and_glob(tmp_path: Path) -> None:
246
+ for d in ("vendor", "lib/generated", "web"):
247
+ (tmp_path / d).mkdir(parents=True)
248
+ (tmp_path / d / "bad.py").write_text("eval(x)\n")
249
+ (tmp_path / "web" / "bundle.js").write_text("eval(x);\n")
250
+ (tmp_path / "web.config").write_text("Content-Security-Policy: default-src 'self'")
251
+ assert len(find_injection.run([], root=tmp_path).findings) == 4
252
+ assert len(find_injection.run([], root=tmp_path, exclude=["vendor"]).findings) == 3
253
+ assert len(find_injection.run([], root=tmp_path, exclude=["lib/generated"]).findings) == 3
254
+ assert len(find_injection.run([], root=tmp_path, exclude=["web/*.js", "vendor/"]).findings) == 2
255
+ assert [f.path.name for f in find_injection.run([], root=tmp_path, exclude=["vendor", "lib", "web/*.js"]).findings] == ["bad.py"]
256
+
257
+
258
+ def test_exclude_dirs_from_pyproject(tmp_path: Path) -> None:
259
+ (tmp_path / "pyproject.toml").write_text('[tool.bdt.lint]\nexclude_dirs = ["legacy"]\n')
260
+ (tmp_path / "legacy").mkdir()
261
+ (tmp_path / "legacy" / "bad.py").write_text("eval(x)\n")
262
+ assert find_injection.run([], root=tmp_path).findings == []
263
+
264
+
265
+ def test_gitignored_files_are_skipped_unless_disabled(tmp_path: Path) -> None:
266
+ subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True)
267
+ (tmp_path / ".gitignore").write_text("built/\n")
268
+ (tmp_path / "built").mkdir()
269
+ (tmp_path / "built" / "bad.py").write_text("eval(x)\n")
270
+ (tmp_path / "src.py").write_text("eval(y)\n")
271
+ assert [f.path.name for f in find_injection.run([], root=tmp_path).findings] == ["src.py"]
272
+ assert len(find_injection.run([], root=tmp_path, respect_gitignore=False).findings) == 2
273
+
274
+
275
+ def test_min_js_is_skipped_long_lines_are_not_and_findings_deduplicated(tmp_path: Path) -> None:
276
+ (tmp_path / "app.min.js").write_text("eval(x);")
277
+ (tmp_path / "bundle.js").write_text("a=1;" * 400 + "eval(x);\n")
278
+ (tmp_path / "dup.js").write_text("el.innerHTML = a; el.innerHTML = b;\n")
279
+ (tmp_path / "web.config").write_text("Content-Security-Policy: default-src 'self'")
280
+ result = find_injection.run([], root=tmp_path)
281
+ assert [(f.path.name, f.rule) for f in result.findings] == [("bundle.js", "fe-eval"), ("dup.js", "fe-inner-html")]
282
+
283
+
284
+ def test_sqlglot_expression_sql_is_not_flagged(tmp_path: Path) -> None:
285
+ (tmp_path / "a.py").write_text(
286
+ """import sqlglot
287
+ from sqlglot import exp, select
288
+ from typing import cast, LiteralString
289
+
290
+
291
+ def run(conn, t):
292
+ expr = select("a").from_(t).where(exp.column("b").eq(1))
293
+ conn.execute(expr.sql())
294
+ conn.execute(sqlglot.parse_one("select 1").sql(dialect="postgres"))
295
+ sql = exp.select("*").from_(exp.to_table(t)).sql("tsql")
296
+ conn.execute(sql)
297
+ conn.execute(cast(LiteralString, select("a").from_(t).sql(pretty=True)))
298
+ """
299
+ )
300
+ assert find_injection.run([], root=tmp_path).findings == []
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.26.0"
25
+ version = "0.27.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },