pr-analyzer-cli 0.1.0__tar.gz → 0.1.1__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 (35) hide show
  1. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/PKG-INFO +11 -10
  2. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/README.md +10 -9
  3. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/pyproject.toml +1 -1
  4. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/ast_analyzer.py +28 -6
  5. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/line_lexer.py +6 -4
  6. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/cache.py +109 -34
  7. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/client.py +14 -6
  8. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/markdown.py +10 -0
  9. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/ratelimit.py +14 -4
  10. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/review.py +16 -10
  11. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/project_config.py +44 -3
  12. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/LICENSE +0 -0
  13. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/__init__.py +0 -0
  14. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/__init__.py +0 -0
  15. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/scorer.py +0 -0
  16. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/cli.py +0 -0
  17. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/config.py +0 -0
  18. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/diff.py +0 -0
  19. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/exceptions.py +0 -0
  20. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/__init__.py +0 -0
  21. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/__init__.py +0 -0
  22. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/chunking.py +0 -0
  23. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/engine.py +0 -0
  24. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/prompts.py +0 -0
  25. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/logging_config.py +0 -0
  26. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/models.py +0 -0
  27. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/pipeline.py +0 -0
  28. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/reporting/__init__.py +0 -0
  29. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/reporting/rich_reporter.py +0 -0
  30. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/__init__.py +0 -0
  31. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/dep_auditor.py +0 -0
  32. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/scanner.py +0 -0
  33. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/semgrep_runner.py +0 -0
  34. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/tools.py +0 -0
  35. {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/suppression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pr-analyzer-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Score GitHub pull requests with static analysis, security scanning and an optional LLM review
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -43,6 +43,7 @@ Description-Content-Type: text/markdown
43
43
  # PR Analyzer
44
44
 
45
45
  [![CI](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml/badge.svg)](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml)
46
+ [![PyPI](https://img.shields.io/pypi/v/pr-analyzer-cli)](https://pypi.org/project/pr-analyzer-cli/)
46
47
 
47
48
  An AI-powered pull request analysis tool that combines static analysis, security scanning, and LLM review into a single CLI command. Point it at any GitHub PR URL and it returns a quality score, a letter grade, line-level suggestions, and an optional GitHub review with inline comments.
48
49
 
@@ -124,8 +125,6 @@ Or straight from GitHub, for the latest `main`:
124
125
  pip install "pr-analyzer-cli[security] @ git+https://github.com/Quantumstringfloops/PRanalyzer.git#subdirectory=pr-analyzer"
125
126
  ```
126
127
 
127
- > Until the first release is on PyPI, use the GitHub line.
128
-
129
128
  `[security]` adds Semgrep and pip-audit. Leave it out for a smaller install, or add `openai` or `anthropic` for those LLM providers, for example `[security,openai]`. `pipx install` works too.
130
129
 
131
130
  ### 2. Analyse a pull request
@@ -227,11 +226,11 @@ The LLM prompt treats the PR the same way: its title, file names and diff sit be
227
226
  The pattern rules read one added line at a time and tell code, string literals and comments apart in Python, JavaScript and TypeScript, and in C-family languages such as Java, Go or Rust (`//`, `/* */`, `"..."` and character literals such as `'"'`). In Python a line holding only a docstring is read like a comment, and in C, C++ and C# so is a line starting with `#`, a preprocessor directive such as `#define`, `#pragma` or `#region`. PHP and other languages are matched as before, anywhere in the line, and a line starting with `#`, `//`, `/*` or `*` counts as a comment:
228
227
 
229
228
  - Rules about calls and settings (`eval(`, `pickle.load`, `yaml.load`, `subprocess … shell=True`, `debug=True`, `print(`, `console.log`) ignore text inside strings and comments. The code in an f-string's `{...}` or a template literal's `${...}` is checked like any other code, though a format spec such as `:>8` is text. The SQL-injection rule still sees a string being formatted into a query.
230
- - `todo-comment` counts `TODO` and `FIXME` anywhere but inside a string literal: in comments and docstrings, and on the middle lines of a docstring or a `/* */` comment, but not in `status = "TODO"`.
229
+ - `todo-comment` counts `TODO` and `FIXME` anywhere but inside a string literal: in comments and docstrings, and on the middle lines of a docstring or a `/* */` comment, but not in `status = "TODO"`. Outside PHP and the other languages matched as before, it doesn't count a marker that code uses as a name either: one right after `.` or `::` (`Status.TODO`), `case ` (`case TODO:`), or a name and `(` or `[` (`move(TODO)`, `counts[TODO]`); after `=`, `,` or `{` and at most one space (`status == TODO`, `{ TODO: 1 }`); before `=` or `,` (`TODO = "todo"`, `enum Status { TODO, DONE }`); or before `:` and a string, a number, `{` or `[` (`TODO: "todo",` on a line of its own). A marker followed by `(`, such as Kotlin's `TODO()` or a `TODO(name):` note, always counts, as does every marker in a comment.
231
230
  - The secret rules read the whole line, strings and comments included, since that is where secrets end up.
232
- - `magic-number` skips numbers that are already named or explain themselves: a keyword argument or default value (`timeout=30`), an assignment (`retries = 5`), a dict key or object property (`"port": 8080`, `{ port: 8080 }`), a constant definition (`MAX_SIZE = 10`), a slice or index (`line[:72]`), a line about a version (`sys.version_info >= (3, 11)`), and well-known values: 10, 100 and 1000; 24, 60, 3600 and 86400; powers of two from 16 to 65536, 255 and 65535; ports 80 and 443; and the standard HTTP status codes. Test files are skipped.
231
+ - `magic-number` skips numbers that are already named or explain themselves: a keyword argument or default value (`timeout=30`), an assignment (`retries = 5`), a dict key or object property (`"port": 8080`, `{ port: 8080 }`), a constant definition (`MAX_SIZE = 10`), a slice or index within 120 characters of its `[` (`line[:72]`), a line about a version (`sys.version_info >= (3, 11)`), and well-known values: 10, 100 and 1000; 24, 60, 3600 and 86400; powers of two from 16 to 65536, 255 and 65535; ports 80 and 443; and the standard HTTP status codes. Test files are skipped.
233
232
 
234
- Because each line is read on its own, the middle lines of a multi-line string (a docstring, a template literal) or of a `/* */` comment are read as code unless they start like a comment (`*`, `#`, `//`), and JavaScript regex literals aren't recognised.
233
+ Because each line is read on its own, the middle lines of a multi-line string (a docstring, a template literal) or of a `/* */` comment are read as code unless they start like a comment (`*`, `#`, `//`), and JavaScript regex literals aren't recognised. In a line with more than 20 `{...}` or `${...}` fields nested in one another, everything from the 21st field on is read as code, string text included, so nothing there goes unchecked.
235
234
 
236
235
  ### Project configuration (`.pr-analyzer.toml`)
237
236
 
@@ -261,7 +260,7 @@ The values shown are the defaults. Severities can be written in any case.
261
260
 
262
261
  **Where it is read from.** When a PR is analysed, `.pr-analyzer.toml` is read from the PR's **base branch** through the GitHub API, never from the PR itself, so a PR can't loosen its own checks; a change to the file takes effect once it is merged, and until then the PR's copy is only checked (see below). A file in the current directory is never picked up (in CI that is the PR's own checkout). `--config PATH` uses a local file instead. No file means the defaults.
263
262
 
264
- **Validation.** Unknown keys, unknown rule ids, bad severities, unsupported `ignore_paths` patterns and thresholds that aren't positive whole numbers make the file invalid. Every problem is named, with a suggestion for likely typos, and what happens next depends on where the file came from:
263
+ **Validation.** Unknown keys, unknown rule ids, bad severities, unsupported `ignore_paths` patterns and thresholds that aren't positive whole numbers make the file invalid. Every problem is named, with a suggestion for likely typos. An integer outside TOML's 64-bit range, or tables or arrays nested more than 100 levels deep, make the file invalid as well, and its keys aren't checked then. What happens next depends on where the file came from:
265
264
 
266
265
  - **A PR that adds or changes `.pr-analyzer.toml`** has its copy checked, though not applied: each problem is a HIGH finding on the file, with the rule id `config`, so with the default `block_on` the PR is blocked until the file is valid. The check runs whatever `ignore_paths` says; `disable = ["config"]` on the base branch turns it off.
267
266
  - **An invalid file on the base branch** doesn't stop the run. The file is ignored, the PR is analysed with the default settings, and a warning names the problems in the report, in the review's notes and on stderr:
@@ -287,7 +286,7 @@ The values shown are the defaults. Severities can be written in any case.
287
286
  - A trailing `/` matches directories only, and a pattern that matches a directory covers every file in it.
288
287
  - Every other character matches itself, case-sensitively, `#` and spaces included (a `.gitignore` would read a leading `#` as a comment and drop trailing spaces).
289
288
 
290
- Negation (a leading `!`), `[` and `]` (character classes), backslashes (escapes) and `**` inside a name before a `/` (like `foo**/bar`, which git itself reads inconsistently) make the file invalid, rather than silently matching something other than what a `.gitignore` would. The report notes which files were skipped.
289
+ Negation (a leading `!`), `[` and `]` (character classes), backslashes (escapes), a doubled slash (`//`) and `**` inside a name before a `/` (like `foo**/bar`, which git itself reads inconsistently) make the file invalid, rather than silently matching something other than what a `.gitignore` would. The report notes which files were skipped.
291
290
 
292
291
  **Rule ids.** Each finding's id is in the JSON report (`"rule"`), and the review summary shows it next to each counted rule.
293
292
 
@@ -375,7 +374,9 @@ No checkout step is needed: the action reads the PR through the GitHub API and i
375
374
  - On `pull_request`, PRs from forks get a read-only token and no secrets, so the review can't be posted; skip them with `if: github.event.pull_request.head.repo.full_name == github.repository` on the job, as this repository's own workflow does.
376
375
  - With `llm-provider: ollama`, set `OLLAMA_URL` in the step's `env:`.
377
376
  - `config` reads a file from the workspace. If the job checks out the PR, that is the PR's own code, which could loosen its own checks, so prefer the base branch's `.pr-analyzer.toml` unless the file comes from somewhere the PR can't change.
378
- - pr-analyzer is installed and run outside the workspace, with Python isolated (`-I`), so files in a checked-out PR can't stand in for pip or any other module, a `.env` file in the PR can't change its settings, and the log can't be fed workflow commands by text from the PR.
377
+ - pr-analyzer is installed and run from folders outside the workspace, and the action's own `python` commands run isolated (`-I`), so they never look for modules in the current directory. A `pip.py` or other module in a checked-out PR can't stand in for the real one that way, and a `.env` file in the PR can't change pr-analyzer's settings.
378
+ - Before installing pr-analyzer, and again before running it, the action removes every environment variable from the job whose name starts with `PYTHON`, such as `PYTHONPATH` or `PYTHONHOME`, so none of them reaches any process it starts from then on, pip and the processes pip starts, pr-analyzer, Semgrep and pip-audit among them. A `PYTHONPATH` that points at a checked-out PR can't make them load its files. Other variables that the action doesn't set itself, such as `PATH` or pip's `PIP_*` settings, reach those processes as the job sets them, so don't point them at the PR's files.
379
+ - The log can't be fed workflow commands by text from the PR.
379
380
 
380
381
  ---
381
382
 
@@ -458,7 +459,7 @@ Results that can't change are kept on disk, in `CACHE_DIR`:
458
459
 
459
460
  Failed requests are never cached, and neither is a file or reply that contains your `GITHUB_TOKEN`, `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`, or any of these: a private key block (PEM, OpenSSH or PGP), a PuTTY key file, or a GitHub, OpenAI or Anthropic (`sk-`), AWS access key ID (`AKIA`, `ASIA`), Slack (`xox?-`), Stripe live (`sk_live_`, `rk_live_`), Google API (`AIza`), GitLab (`glpat-`) or Hugging Face (`hf_`) token. Other secrets, such as an AWS secret access key on its own or a password in a database URL, aren't recognised and can be cached. A damaged entry counts as a miss, entries are written atomically, and the size and age limits are applied at most once an hour. `--no-cache` skips the cache for one run (for a fresh LLM review, say); `CACHE_ENABLED=false` turns it off.
460
461
 
461
- Since the cache may hold code from private repositories, pr-analyzer creates its directory private to your user (mode 700). It uses an existing `CACHE_DIR`, and the `v1` folder in it, only if each is a real directory (not a symbolic link) that belongs to you: one that other users can open too is made private, and anything else turns the cache off for the run, with a warning. Entries are read only from your own files, never through a symbolic link, and pruning deletes nothing but the cache's own entry and temporary files. On Windows, which has no POSIX owners or modes, only symbolic links are refused.
462
+ Since the cache may hold code from private repositories, pr-analyzer creates its directory private to your user (mode 700). It uses an existing `CACHE_DIR`, and the `v1` folder in it, only if each is a real directory (not a symbolic link) that belongs to you: one that other users can open too is made private, and anything else turns the cache off for the run, with a warning. Until then, someone else could have planted, linked or swapped entries in it, so once either folder has been made private, the cache's own files already in `v1` (entries, temporary files and `.last-prune`) are deleted before any is used, with a warning if any were. The folders in `v1` that an entry is read from or written to must be real directories that belong to you too, or the cache is off for the run, with a warning. Entries are read only from your own regular files that have no other hard link, never through a symbolic link; pruning deletes nothing but the cache's own entry and temporary files, and replaces `.last-prune` rather than writing through a symbolic link put in its place. On Windows, which has no POSIX owners or modes, owners aren't checked and nothing is made private.
462
463
 
463
464
  To keep the cache between GitHub Actions runs, restore and save it with `actions/cache`. A saved cache can't be updated, so each run saves a new one and the next run restores the newest:
464
465
 
@@ -1,6 +1,7 @@
1
1
  # PR Analyzer
2
2
 
3
3
  [![CI](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml/badge.svg)](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/pr-analyzer-cli)](https://pypi.org/project/pr-analyzer-cli/)
4
5
 
5
6
  An AI-powered pull request analysis tool that combines static analysis, security scanning, and LLM review into a single CLI command. Point it at any GitHub PR URL and it returns a quality score, a letter grade, line-level suggestions, and an optional GitHub review with inline comments.
6
7
 
@@ -82,8 +83,6 @@ Or straight from GitHub, for the latest `main`:
82
83
  pip install "pr-analyzer-cli[security] @ git+https://github.com/Quantumstringfloops/PRanalyzer.git#subdirectory=pr-analyzer"
83
84
  ```
84
85
 
85
- > Until the first release is on PyPI, use the GitHub line.
86
-
87
86
  `[security]` adds Semgrep and pip-audit. Leave it out for a smaller install, or add `openai` or `anthropic` for those LLM providers, for example `[security,openai]`. `pipx install` works too.
88
87
 
89
88
  ### 2. Analyse a pull request
@@ -185,11 +184,11 @@ The LLM prompt treats the PR the same way: its title, file names and diff sit be
185
184
  The pattern rules read one added line at a time and tell code, string literals and comments apart in Python, JavaScript and TypeScript, and in C-family languages such as Java, Go or Rust (`//`, `/* */`, `"..."` and character literals such as `'"'`). In Python a line holding only a docstring is read like a comment, and in C, C++ and C# so is a line starting with `#`, a preprocessor directive such as `#define`, `#pragma` or `#region`. PHP and other languages are matched as before, anywhere in the line, and a line starting with `#`, `//`, `/*` or `*` counts as a comment:
186
185
 
187
186
  - Rules about calls and settings (`eval(`, `pickle.load`, `yaml.load`, `subprocess … shell=True`, `debug=True`, `print(`, `console.log`) ignore text inside strings and comments. The code in an f-string's `{...}` or a template literal's `${...}` is checked like any other code, though a format spec such as `:>8` is text. The SQL-injection rule still sees a string being formatted into a query.
188
- - `todo-comment` counts `TODO` and `FIXME` anywhere but inside a string literal: in comments and docstrings, and on the middle lines of a docstring or a `/* */` comment, but not in `status = "TODO"`.
187
+ - `todo-comment` counts `TODO` and `FIXME` anywhere but inside a string literal: in comments and docstrings, and on the middle lines of a docstring or a `/* */` comment, but not in `status = "TODO"`. Outside PHP and the other languages matched as before, it doesn't count a marker that code uses as a name either: one right after `.` or `::` (`Status.TODO`), `case ` (`case TODO:`), or a name and `(` or `[` (`move(TODO)`, `counts[TODO]`); after `=`, `,` or `{` and at most one space (`status == TODO`, `{ TODO: 1 }`); before `=` or `,` (`TODO = "todo"`, `enum Status { TODO, DONE }`); or before `:` and a string, a number, `{` or `[` (`TODO: "todo",` on a line of its own). A marker followed by `(`, such as Kotlin's `TODO()` or a `TODO(name):` note, always counts, as does every marker in a comment.
189
188
  - The secret rules read the whole line, strings and comments included, since that is where secrets end up.
190
- - `magic-number` skips numbers that are already named or explain themselves: a keyword argument or default value (`timeout=30`), an assignment (`retries = 5`), a dict key or object property (`"port": 8080`, `{ port: 8080 }`), a constant definition (`MAX_SIZE = 10`), a slice or index (`line[:72]`), a line about a version (`sys.version_info >= (3, 11)`), and well-known values: 10, 100 and 1000; 24, 60, 3600 and 86400; powers of two from 16 to 65536, 255 and 65535; ports 80 and 443; and the standard HTTP status codes. Test files are skipped.
189
+ - `magic-number` skips numbers that are already named or explain themselves: a keyword argument or default value (`timeout=30`), an assignment (`retries = 5`), a dict key or object property (`"port": 8080`, `{ port: 8080 }`), a constant definition (`MAX_SIZE = 10`), a slice or index within 120 characters of its `[` (`line[:72]`), a line about a version (`sys.version_info >= (3, 11)`), and well-known values: 10, 100 and 1000; 24, 60, 3600 and 86400; powers of two from 16 to 65536, 255 and 65535; ports 80 and 443; and the standard HTTP status codes. Test files are skipped.
191
190
 
192
- Because each line is read on its own, the middle lines of a multi-line string (a docstring, a template literal) or of a `/* */` comment are read as code unless they start like a comment (`*`, `#`, `//`), and JavaScript regex literals aren't recognised.
191
+ Because each line is read on its own, the middle lines of a multi-line string (a docstring, a template literal) or of a `/* */` comment are read as code unless they start like a comment (`*`, `#`, `//`), and JavaScript regex literals aren't recognised. In a line with more than 20 `{...}` or `${...}` fields nested in one another, everything from the 21st field on is read as code, string text included, so nothing there goes unchecked.
193
192
 
194
193
  ### Project configuration (`.pr-analyzer.toml`)
195
194
 
@@ -219,7 +218,7 @@ The values shown are the defaults. Severities can be written in any case.
219
218
 
220
219
  **Where it is read from.** When a PR is analysed, `.pr-analyzer.toml` is read from the PR's **base branch** through the GitHub API, never from the PR itself, so a PR can't loosen its own checks; a change to the file takes effect once it is merged, and until then the PR's copy is only checked (see below). A file in the current directory is never picked up (in CI that is the PR's own checkout). `--config PATH` uses a local file instead. No file means the defaults.
221
220
 
222
- **Validation.** Unknown keys, unknown rule ids, bad severities, unsupported `ignore_paths` patterns and thresholds that aren't positive whole numbers make the file invalid. Every problem is named, with a suggestion for likely typos, and what happens next depends on where the file came from:
221
+ **Validation.** Unknown keys, unknown rule ids, bad severities, unsupported `ignore_paths` patterns and thresholds that aren't positive whole numbers make the file invalid. Every problem is named, with a suggestion for likely typos. An integer outside TOML's 64-bit range, or tables or arrays nested more than 100 levels deep, make the file invalid as well, and its keys aren't checked then. What happens next depends on where the file came from:
223
222
 
224
223
  - **A PR that adds or changes `.pr-analyzer.toml`** has its copy checked, though not applied: each problem is a HIGH finding on the file, with the rule id `config`, so with the default `block_on` the PR is blocked until the file is valid. The check runs whatever `ignore_paths` says; `disable = ["config"]` on the base branch turns it off.
225
224
  - **An invalid file on the base branch** doesn't stop the run. The file is ignored, the PR is analysed with the default settings, and a warning names the problems in the report, in the review's notes and on stderr:
@@ -245,7 +244,7 @@ The values shown are the defaults. Severities can be written in any case.
245
244
  - A trailing `/` matches directories only, and a pattern that matches a directory covers every file in it.
246
245
  - Every other character matches itself, case-sensitively, `#` and spaces included (a `.gitignore` would read a leading `#` as a comment and drop trailing spaces).
247
246
 
248
- Negation (a leading `!`), `[` and `]` (character classes), backslashes (escapes) and `**` inside a name before a `/` (like `foo**/bar`, which git itself reads inconsistently) make the file invalid, rather than silently matching something other than what a `.gitignore` would. The report notes which files were skipped.
247
+ Negation (a leading `!`), `[` and `]` (character classes), backslashes (escapes), a doubled slash (`//`) and `**` inside a name before a `/` (like `foo**/bar`, which git itself reads inconsistently) make the file invalid, rather than silently matching something other than what a `.gitignore` would. The report notes which files were skipped.
249
248
 
250
249
  **Rule ids.** Each finding's id is in the JSON report (`"rule"`), and the review summary shows it next to each counted rule.
251
250
 
@@ -333,7 +332,9 @@ No checkout step is needed: the action reads the PR through the GitHub API and i
333
332
  - On `pull_request`, PRs from forks get a read-only token and no secrets, so the review can't be posted; skip them with `if: github.event.pull_request.head.repo.full_name == github.repository` on the job, as this repository's own workflow does.
334
333
  - With `llm-provider: ollama`, set `OLLAMA_URL` in the step's `env:`.
335
334
  - `config` reads a file from the workspace. If the job checks out the PR, that is the PR's own code, which could loosen its own checks, so prefer the base branch's `.pr-analyzer.toml` unless the file comes from somewhere the PR can't change.
336
- - pr-analyzer is installed and run outside the workspace, with Python isolated (`-I`), so files in a checked-out PR can't stand in for pip or any other module, a `.env` file in the PR can't change its settings, and the log can't be fed workflow commands by text from the PR.
335
+ - pr-analyzer is installed and run from folders outside the workspace, and the action's own `python` commands run isolated (`-I`), so they never look for modules in the current directory. A `pip.py` or other module in a checked-out PR can't stand in for the real one that way, and a `.env` file in the PR can't change pr-analyzer's settings.
336
+ - Before installing pr-analyzer, and again before running it, the action removes every environment variable from the job whose name starts with `PYTHON`, such as `PYTHONPATH` or `PYTHONHOME`, so none of them reaches any process it starts from then on, pip and the processes pip starts, pr-analyzer, Semgrep and pip-audit among them. A `PYTHONPATH` that points at a checked-out PR can't make them load its files. Other variables that the action doesn't set itself, such as `PATH` or pip's `PIP_*` settings, reach those processes as the job sets them, so don't point them at the PR's files.
337
+ - The log can't be fed workflow commands by text from the PR.
337
338
 
338
339
  ---
339
340
 
@@ -416,7 +417,7 @@ Results that can't change are kept on disk, in `CACHE_DIR`:
416
417
 
417
418
  Failed requests are never cached, and neither is a file or reply that contains your `GITHUB_TOKEN`, `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`, or any of these: a private key block (PEM, OpenSSH or PGP), a PuTTY key file, or a GitHub, OpenAI or Anthropic (`sk-`), AWS access key ID (`AKIA`, `ASIA`), Slack (`xox?-`), Stripe live (`sk_live_`, `rk_live_`), Google API (`AIza`), GitLab (`glpat-`) or Hugging Face (`hf_`) token. Other secrets, such as an AWS secret access key on its own or a password in a database URL, aren't recognised and can be cached. A damaged entry counts as a miss, entries are written atomically, and the size and age limits are applied at most once an hour. `--no-cache` skips the cache for one run (for a fresh LLM review, say); `CACHE_ENABLED=false` turns it off.
418
419
 
419
- Since the cache may hold code from private repositories, pr-analyzer creates its directory private to your user (mode 700). It uses an existing `CACHE_DIR`, and the `v1` folder in it, only if each is a real directory (not a symbolic link) that belongs to you: one that other users can open too is made private, and anything else turns the cache off for the run, with a warning. Entries are read only from your own files, never through a symbolic link, and pruning deletes nothing but the cache's own entry and temporary files. On Windows, which has no POSIX owners or modes, only symbolic links are refused.
420
+ Since the cache may hold code from private repositories, pr-analyzer creates its directory private to your user (mode 700). It uses an existing `CACHE_DIR`, and the `v1` folder in it, only if each is a real directory (not a symbolic link) that belongs to you: one that other users can open too is made private, and anything else turns the cache off for the run, with a warning. Until then, someone else could have planted, linked or swapped entries in it, so once either folder has been made private, the cache's own files already in `v1` (entries, temporary files and `.last-prune`) are deleted before any is used, with a warning if any were. The folders in `v1` that an entry is read from or written to must be real directories that belong to you too, or the cache is off for the run, with a warning. Entries are read only from your own regular files that have no other hard link, never through a symbolic link; pruning deletes nothing but the cache's own entry and temporary files, and replaces `.last-prune` rather than writing through a symbolic link put in its place. On Windows, which has no POSIX owners or modes, owners aren't checked and nothing is made private.
420
421
 
421
422
  To keep the cache between GitHub Actions runs, restore and save it with `actions/cache`. A saved cache can't be updated, so each run saves a new one and the next run restores the newest:
422
423
 
@@ -3,7 +3,7 @@
3
3
  # name that only differs from it in case, punctuation or l/i/o lookalikes (so "pranalyzer" too).
4
4
  # The command stays `pr-analyzer` and the import package `pr_analyzer`.
5
5
  name = "pr-analyzer-cli"
6
- version = "0.1.0"
6
+ version = "0.1.1"
7
7
  description = "Score GitHub pull requests with static analysis, security scanning and an optional LLM review"
8
8
  authors = [{ name = "Neil Mathew" }]
9
9
  license = "MIT"
@@ -79,20 +79,41 @@ def explained_number(code: str, match: re.Match[str]) -> bool:
79
79
  )
80
80
 
81
81
 
82
+ # A to-do marker in code can be a name, such as a task status, rather than a note. It is one
83
+ # right after "." or "::" (a member), "case " (a case label), or a name and "(" or "[" (an argument
84
+ # or an index); after "=", "," or "{" and at most one space (a value, an item or a key); before
85
+ # "=" or "," (assigned, compared or an item); or before ":" and a string, a number, "{" or "["
86
+ # (a key on a line of its own). NAME_BEFORE is all lookbehinds: no text before a marker is read.
87
+ NAME_BEFORE = re.compile(r"(?<=\.)|(?<=::)|(?<=\bcase\s)|(?<=\w[(\[])|(?<=[=,{])|(?<=[=,{]\s)")
88
+ NAME_AFTER = re.compile(r"\s*(?:[=,]|:\s*[\"'`\d{\[])")
89
+
90
+
91
+ def used_as_name(code: str, match: re.Match[str]) -> bool:
92
+ """A to-do marker that names something in code. One followed by "(" always counts as a
93
+ marker: it calls Kotlin's function for unfinished code, or starts a note naming its owner."""
94
+ start, end = match.span()
95
+ if code.startswith("(", end):
96
+ return False
97
+ return NAME_BEFORE.match(code, start) is not None or NAME_AFTER.match(code, end) is not None
98
+
99
+
82
100
  # Each rule runs on one added line at a time (the text after the leading "+"). Its "id" is the
83
101
  # stable name .pr-analyzer.toml uses to switch it off. Its "scope" picks the views of the line it
84
102
  # reads (see line_lexer): "code" leaves out comments and the contents of strings, "code+comments"
85
- # only the contents of strings. An optional "exempt" callable can wave off individual matches.
86
- # Hardcoded secrets are not checked here: the security scanner owns them, and checking them in
87
- # both places used to penalise the same line twice.
103
+ # only the contents of strings. An optional "skip_if" regex skips a line and an optional "exempt"
104
+ # callable waves off individual matches; both look at code only, as every match in a comment
105
+ # counts. Hardcoded secrets are not checked here: the security scanner owns them, and checking
106
+ # them in both places used to penalise the same line twice.
88
107
  PATTERNS: list[dict[str, Any]] = [
89
108
  {
90
109
  "id": "todo-comment",
91
110
  "regex": re.compile(r"\b(TODO|FIXME)\b"),
92
111
  "extensions": CODE_EXTENSIONS,
93
112
  # In a string the marker is data, such as a status value, not a note to the team. The
94
- # middle lines of a docstring or a /* */ comment read as code, so code counts too.
113
+ # middle lines of a docstring or a /* */ comment read as code, so code counts too, apart
114
+ # from a marker that code uses as a name.
95
115
  "scope": "code+comments",
116
+ "exempt": used_as_name,
96
117
  "severity": "LOW",
97
118
  "category": "style",
98
119
  "title": "TODO/FIXME comment added",
@@ -306,8 +327,9 @@ def patterns_for(filename: str, disabled: Collection[str] = ()) -> list[dict[str
306
327
 
307
328
 
308
329
  def line_matches(pattern: dict[str, Any], parts: LineParts) -> bool:
309
- views = (parts.code, parts.comments) if pattern["scope"] == "code+comments" else (parts.code,)
310
- return any(view_matches(pattern, view) for view in views)
330
+ if pattern["scope"] == "code+comments" and pattern["regex"].search(parts.comments):
331
+ return True # skip_if and exempt are about code: in a comment every match counts
332
+ return view_matches(pattern, parts.code)
311
333
 
312
334
 
313
335
  def view_matches(pattern: dict[str, Any], text: str) -> bool:
@@ -13,6 +13,7 @@ In the code view a string keeps its quotes and prefix but its contents become sp
13
13
  SQL-injection rule, still see it. The replacement fields of an f-string or a template literal are
14
14
  code, so they stay; only the text around them, and a format spec, is blanked
15
15
  (`f"id {run(x):>8}"` -> `f" {run(x) }"`, `` `a ${run(b)}` `` -> `` ` ${run(b)}` ``).
16
+ Past MAX_NESTING fields nested in one another, the rest of the line is kept as code, text and all.
16
17
 
17
18
  Python, JavaScript/TypeScript and C-family files are lexed; for any other language both views are
18
19
  the whole line, which is how every rule matched before. Some lines are comments as a whole: a line
@@ -33,7 +34,8 @@ from dataclasses import dataclass, field, replace
33
34
 
34
35
  COMMENT_PREFIXES = ("#", "//", "/*", "*")
35
36
  # replacement fields nested deeper than this (never seen in real code) end the lexing of a line,
36
- # so a crafted line can't exhaust the recursion limit
37
+ # so a crafted line can't exhaust the recursion limit. The rest of the line is kept as code, so
38
+ # nothing in it, such as a call after the string, goes unchecked.
37
39
  MAX_NESTING = 20
38
40
 
39
41
  _STRING = r'"(?:\\(?:.|$)|[^"\\])*(?:"|$)' # an unclosed string runs to the end of the line
@@ -44,7 +46,6 @@ _BLOCK_COMMENT = r"/\*.*?(?:\*/|$)"
44
46
  _BRACKETS = r"|(?P<open>[(\[{])|(?P<close>[)\]}])"
45
47
  # a format spec is text up to the end of its field, apart from the fields nested in it
46
48
  _FORMAT_SPEC = re.compile(r"[^{}]*")
47
- _REST_OF_LINE = re.compile(r".*")
48
49
 
49
50
 
50
51
  @dataclass(frozen=True)
@@ -227,8 +228,9 @@ class _Lexer:
227
228
  def lex_field(self, pos: int) -> int:
228
229
  """Lex a replacement field from just after its opener: its code, then in Python its
229
230
  format spec, up to and including the closing brace. Returns where the field ends."""
230
- if self.nesting == MAX_NESTING: # the rest of the line counts as text
231
- return self.skip_text(_REST_OF_LINE, pos)
231
+ if self.nesting == MAX_NESTING: # the rest of the line counts as code, hiding nothing
232
+ self.keep_code(self.text[pos:])
233
+ return len(self.text)
232
234
  self.nesting += 1
233
235
  pos = self.lex_code(pos, in_field=True)
234
236
  if self.text.startswith(":", pos):
@@ -11,13 +11,18 @@ an entry that can't be read or decoded counts as a miss. Entries unused for CACH
11
11
  expire, and once the cache outgrows CACHE_MAX_SIZE_MB the least recently used go first.
12
12
 
13
13
  The cache may hold code from private repositories, and anyone else who could write to it could
14
- plant entries, or make prune() delete files elsewhere through a symbolic link. So <dir> and
15
- <dir>/v1 are created private to the user (PRIVATE_MODE), and before either is used it must be a
16
- real directory (not a symbolic link) that belongs to the user: one of the user's that others can
17
- use too is made private, and anything else turns the cache off for the run, with a warning.
18
- Entries are read only from the user's own regular files, never through a symbolic link, and
19
- prune() deletes nothing but entry and temporary files in <kind>/<2 hex digits>/, following no
20
- symbolic link.
14
+ plant entries, swap them, or make prune() delete files elsewhere through a symbolic link. So
15
+ <dir> and <dir>/v1 are created private to the user (PRIVATE_MODE), and before either is used it
16
+ must be a real directory (not a symbolic link) that belongs to the user: one of the user's that
17
+ others can use too is made private, and anything else turns the cache off for the run, with a
18
+ warning. Until then others could have planted, linked or swapped entries in it, so once either
19
+ has been made private the cache's own files in <dir>/v1 are deleted before any is used, with a
20
+ warning if there were any (see _clear). The <kind> and <2 hex digits> directories that hold an
21
+ entry must be real directories of the user's too, or the cache is off for the run, with a
22
+ warning. Entries are read only from the user's own regular files with no other hard link,
23
+ never through a symbolic link. prune() deletes nothing but entry and temporary files in
24
+ <kind>/<2 hex digits>/, following no symbolic link, and replaces its stamp file rather than
25
+ writing through a link put in its place.
21
26
 
22
27
  Callers never cache errors. A value isn't written if it contains one of our own keys (`secrets`:
23
28
  open_cache passes GITHUB_TOKEN, OPENAI_API_KEY and ANTHROPIC_API_KEY; values under 8 characters
@@ -111,28 +116,44 @@ def current_uid() -> int | None:
111
116
 
112
117
 
113
118
  def directory_problem(path: Path, uid: int | None) -> str | None:
114
- """Why the cache mustn't use the directory at `path`, or None if only the user can use it.
115
- A directory of the user's that others can use too is made private first. Raises OSError if
116
- there's nothing at `path`, or not a directory."""
119
+ """Why the cache mustn't use the directory at `path`, or None if it is a real directory that
120
+ belongs to the user. Raises OSError if there's nothing at `path`, or not a directory."""
117
121
  info = os.lstat(path)
118
122
  if stat.S_ISLNK(info.st_mode):
119
123
  return "it is a symbolic link"
120
124
  if not stat.S_ISDIR(info.st_mode):
121
125
  raise NotADirectoryError(f"Not a directory: {str(path)!r}")
122
- if uid is None:
123
- return None
124
- if info.st_uid != uid:
126
+ if uid is not None and info.st_uid != uid:
125
127
  return "it belongs to another user"
126
- if info.st_mode & 0o077:
127
- with contextlib.suppress(OSError):
128
- os.chmod(path, PRIVATE_MODE) # pr-analyzer: ignore - makes the folder private (0700)
129
- if os.lstat(path).st_mode & 0o077:
130
- return "other users can open it, and it can't be made private"
131
128
  return None
132
129
 
133
130
 
131
+ def others_can_use(path: Path, uid: int | None) -> bool:
132
+ # where files have no POSIX owner and mode (Windows), nothing is made private
133
+ return uid is not None and bool(os.lstat(path).st_mode & 0o077)
134
+
135
+
136
+ def make_private(path: Path) -> str | None:
137
+ """Makes the directory at `path` private to the user: why that failed, or None."""
138
+ with contextlib.suppress(OSError):
139
+ os.chmod(path, PRIVATE_MODE) # pr-analyzer: ignore - makes the folder private (0700)
140
+ if os.lstat(path).st_mode & 0o077:
141
+ return "other users can open it, and it can't be made private"
142
+ return None
143
+
144
+
145
+ def warn_unsafe(path: Path, problem: str, fix: str) -> None:
146
+ log.warning("cache_dir_unsafe", path=str(path), problem=problem,
147
+ hint=f"caching off for this run: {fix}")
148
+
149
+
134
150
  def own_file(info: os.stat_result, uid: int | None) -> bool:
135
- return stat.S_ISREG(info.st_mode) and (uid is None or info.st_uid == uid)
151
+ # the cache never gives a file a second hard link: a file with one may be any other file
152
+ # (another entry, say) linked into an entry's place
153
+ return (
154
+ stat.S_ISREG(info.st_mode) and info.st_nlink <= 1
155
+ and (uid is None or info.st_uid == uid)
156
+ )
136
157
 
137
158
 
138
159
  def subdirectories(path: Path) -> list[Path]:
@@ -177,8 +198,8 @@ class DiskCache:
177
198
  self._lock = threading.Lock()
178
199
  self.hits = self.misses = self.writes = 0
179
200
  self.writable = True # until a write fails: then the run carries on without writing
180
- self._checked = False # <dir> and <dir>/v1 passed directory_problem
181
- self._unsafe = False # one of them didn't: the cache is off for the run
201
+ self._checked = False # <dir> and <dir>/v1 passed their check
202
+ self._unsafe = False # a directory of the cache didn't: the cache is off for the run
182
203
 
183
204
  @classmethod
184
205
  def disabled(cls) -> "DiskCache":
@@ -191,7 +212,9 @@ class DiskCache:
191
212
  def get(self, kind: str, *parts: str) -> str | None:
192
213
  if not self.enabled:
193
214
  return None
194
- value = self._read(self._path(kind, parts)) if self._usable(create=False) else None
215
+ path = self._path(kind, parts)
216
+ usable = self._usable(create=False) and self._entry_dirs_usable(path, create=False)
217
+ value = self._read(path) if usable else None
195
218
  with self._lock:
196
219
  if value is None:
197
220
  self.misses += 1
@@ -205,9 +228,8 @@ class DiskCache:
205
228
  path = self._path(kind, parts)
206
229
  data = zlib.compress(json.dumps({"value": value}).encode("ascii"))
207
230
  try:
208
- if not self._usable(create=True):
231
+ if not (self._usable(create=True) and self._entry_dirs_usable(path, create=True)):
209
232
  return
210
- path.parent.mkdir(parents=True, exist_ok=True)
211
233
  write_atomically(path, data, mtime=self.clock())
212
234
  except OSError as e:
213
235
  # a cache that can't be written must never fail the analysis, nor log every file
@@ -251,9 +273,11 @@ class DiskCache:
251
273
  break
252
274
  discard(path)
253
275
  total -= size
276
+ stamp = self.root / PRUNE_STAMP
254
277
  with contextlib.suppress(OSError):
255
- stamp = self.root / PRUNE_STAMP
256
- stamp.touch()
278
+ # made anew, not touched: a symbolic link put in its place is removed, not followed
279
+ discard(stamp)
280
+ os.close(os.open(stamp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
257
281
  os.utime(stamp, (now, now))
258
282
 
259
283
  def _path(self, kind: str, parts: tuple[str, ...]) -> Path:
@@ -266,7 +290,8 @@ class DiskCache:
266
290
  try:
267
291
  with open(os.open(path, READ_FLAGS), "rb") as handle:
268
292
  info = os.fstat(handle.fileno())
269
- if not own_file(info, current_uid()):
293
+ # Windows has no O_NOFOLLOW: there, opening a symbolic link opens its target
294
+ if stat.S_ISLNK(os.lstat(path).st_mode) or not own_file(info, current_uid()):
270
295
  return None # nobody else's file is ever served as an entry
271
296
  expired = now - info.st_mtime > self.max_age_seconds
272
297
  data = b"" if expired else handle.read()
@@ -304,26 +329,72 @@ class DiskCache:
304
329
  return self._checked
305
330
 
306
331
  def _check_directories(self, create: bool) -> None:
332
+ """Checks <dir> and <dir>/v1, making either private if others can use it too. If one
333
+ was, whatever is in <dir>/v1 may have been planted, so the cache's own files there are
334
+ deleted before any is used."""
307
335
  assert self.root is not None
336
+ uid = current_uid()
337
+ made_private = False
308
338
  for path in (self.root.parent, self.root):
309
339
  if create:
310
340
  # private: the cache holds file contents, which may come from private repositories
311
341
  path.mkdir(mode=PRIVATE_MODE, parents=True, exist_ok=True)
312
- problem = directory_problem(path, current_uid())
342
+ problem = directory_problem(path, uid)
343
+ if problem is None and others_can_use(path, uid):
344
+ made_private = True
345
+ problem = make_private(path)
313
346
  if problem is not None:
314
347
  self._unsafe = True
315
- log.warning("cache_dir_unsafe", path=str(path), problem=problem,
316
- hint="caching off for this run: use a CACHE_DIR of your own")
348
+ warn_unsafe(path, problem, fix="use a CACHE_DIR of your own")
317
349
  return
350
+ if made_private:
351
+ self._clear()
318
352
  self._checked = True
319
353
 
354
+ def _entry_dirs_usable(self, path: Path, create: bool) -> bool:
355
+ """Whether <kind> and <kind>/<2 hex digits>, which hold the entry at `path`, are real
356
+ directories of the user's, after being made if `create`. Through a symbolic link, or in
357
+ someone else's directory, others could read entries or swap them, so either turns the
358
+ cache off for the run. Raises OSError if `create` and they can't be made."""
359
+ uid = current_uid()
360
+ for directory in (path.parent.parent, path.parent):
361
+ try:
362
+ if create:
363
+ with contextlib.suppress(FileExistsError):
364
+ directory.mkdir()
365
+ problem = directory_problem(directory, uid)
366
+ except OSError:
367
+ if create:
368
+ raise
369
+ return False # not made yet, so there's nothing to read
370
+ if problem is not None:
371
+ with self._lock:
372
+ first, self._unsafe = not self._unsafe, True
373
+ if first:
374
+ warn_unsafe(directory, problem, fix="delete it, or use another CACHE_DIR")
375
+ return False
376
+ return True
377
+
378
+ def _clear(self) -> None:
379
+ """Deletes the cache's own files: every entry and temporary file that prune() may delete,
380
+ whatever its age, and the prune stamp. A warning says how many went, if any did."""
381
+ assert self.root is not None
382
+ paths = [path for path, _ in self._entries()] + [self.root / PRUNE_STAMP]
383
+ deleted = sum(discard(path) for path in paths)
384
+ if deleted:
385
+ log.warning("cache_cleared", path=str(self.root), files=deleted,
386
+ reason="others could use the cache folder until it was made private")
387
+
320
388
  def _prune_due(self) -> bool:
321
389
  assert self.root is not None
322
390
  try:
323
- last = (self.root / PRUNE_STAMP).stat().st_mtime
391
+ info = os.lstat(self.root / PRUNE_STAMP)
324
392
  except OSError:
325
393
  return True
326
- return self.clock() - last > PRUNE_INTERVAL_SECONDS
394
+ # a stamp that isn't the cache's own file, such as a planted link, can't put pruning off
395
+ if not own_file(info, current_uid()):
396
+ return True
397
+ return self.clock() - info.st_mtime > PRUNE_INTERVAL_SECONDS
327
398
 
328
399
  def _entries(self) -> Iterator[tuple[Path, os.stat_result]]:
329
400
  """The entry and temporary files in <kind>/<2 hex digits>/, with their lstat: the only
@@ -355,9 +426,13 @@ def write_atomically(path: Path, data: bytes, mtime: float) -> None:
355
426
  raise
356
427
 
357
428
 
358
- def discard(path: Path) -> None:
359
- with contextlib.suppress(OSError):
429
+ def discard(path: Path) -> bool:
430
+ """Deletes the file at `path` (a symbolic link itself, not its target): whether it went."""
431
+ try:
360
432
  path.unlink()
433
+ except OSError:
434
+ return False
435
+ return True
361
436
 
362
437
 
363
438
  def open_cache(enabled: bool = True) -> DiskCache:
@@ -21,8 +21,9 @@ from pr_analyzer.exceptions import (
21
21
  RateLimitError,
22
22
  RepositoryNotFoundError,
23
23
  )
24
- from pr_analyzer.github.markdown import neutralise, neutralise_line
24
+ from pr_analyzer.github.markdown import comparable, neutralise, neutralise_line
25
25
  from pr_analyzer.github.ratelimit import (
26
+ WAIT_SLICE_SECONDS,
26
27
  RateLimitExceeded,
27
28
  RateLimitPause,
28
29
  RateLimitRetry,
@@ -210,8 +211,13 @@ class GithubClient:
210
211
  )
211
212
  try:
212
213
  futures = [pool.submit(fetch, file) for file in files]
213
- # a failure is raised as soon as it happens, not once the files before it are in
214
- wait(futures, return_when=FIRST_EXCEPTION)
214
+ # a failure is raised as soon as it happens, not once the files before it are in. The
215
+ # wait is sliced: on Windows one long wait would hold Ctrl-C back until it ended.
216
+ pending = set(futures)
217
+ while pending:
218
+ done, pending = wait(pending, timeout=WAIT_SLICE_SECONDS, return_when=FIRST_EXCEPTION)
219
+ if any(f.exception() is not None for f in done):
220
+ break
215
221
  errors = [e for f in futures if f.done() and (e := f.exception()) is not None]
216
222
  if errors:
217
223
  raise errors[0]
@@ -315,10 +321,12 @@ class GithubClient:
315
321
  # every push re-runs the analysis, and each run used to add a whole new review that
316
322
  # repeated every comment. Skip findings already commented on (GitHub clears `line`
317
323
  # once the code under a comment changes, so those get a fresh comment) and update the
318
- # earlier summary in place.
319
- posted = {(c.path, c.line, c.body.strip()) for c in pull.get_review_comments()}
324
+ # earlier summary in place. Bodies are compared in comparable() form, so a comment
325
+ # an earlier version posted, with its zero-width spaces elsewhere, still counts.
326
+ posted = {(c.path, c.line, comparable(c.body)) for c in pull.get_review_comments()}
320
327
  review_comments = [
321
- c for c in review_comments if (c["path"], c["line"], c["body"].strip()) not in posted
328
+ c for c in review_comments
329
+ if (c["path"], c["line"], comparable(c["body"])) not in posted
322
330
  ]
323
331
  if not self._update_summary(pull, summary):
324
332
  review_comments = self._create_review(pull, commit, summary, review_comments)
@@ -105,6 +105,16 @@ def code_span(text: str) -> str:
105
105
  return f"{fence}{padding}{text}{padding}{fence}"
106
106
 
107
107
 
108
+ def comparable(text: str) -> str:
109
+ """The text as the review compares it with comments already posted, whichever version
110
+ posted them: without zero-width spaces, which versions of neutralise() put in different
111
+ places, with every line ending as "\\n", and without spaces or tabs at the end of a line or
112
+ whitespace around the text. Nothing else changes, so text that differs in anything more,
113
+ such as code and its indentation, still differs."""
114
+ lines = _LINE_ENDING.split(str(text).replace(ZERO_WIDTH_SPACE, ""))[::2]
115
+ return "\n".join(line.rstrip(" \t") for line in lines).strip()
116
+
117
+
108
118
  def _neutralise_inline(line: str, trust_code: bool = True) -> tuple[str, bool]:
109
119
  """One line of Markdown neutralised outside its code spans, and whether the later lines of
110
120
  its paragraph can no longer trust theirs: a run of backticks left open could pair with one of
@@ -55,6 +55,11 @@ DEFAULT_MAX_WAIT_SECONDS = 60.0
55
55
  # transient server errors, retried on idempotent requests only
56
56
  SERVER_ERRORS = (500, 502, 503, 504)
57
57
  MAX_RETRIES = 3
58
+ # The longest a wait blocks at a time. On Windows, Python 3.11-3.13 can't interrupt an Event wait
59
+ # the way it interrupts time.sleep (bpo-29971): Ctrl-C raises KeyboardInterrupt on the main thread
60
+ # only once the wait returns, which in slices this long is within half a second. Elsewhere Ctrl-C
61
+ # still cuts a slice short at once.
62
+ WAIT_SLICE_SECONDS = 0.5
58
63
 
59
64
 
60
65
  def lowercase(headers: Mapping[str, str]) -> dict[str, str]:
@@ -137,9 +142,10 @@ class RateLimitPause:
137
142
  request waits for a limit to reset, the others hold back instead of adding to it.
138
143
 
139
144
  Waits are on an Event rather than time.sleep, so cancel() ends them at once: Ctrl-C reaches
140
- only the main thread, and nothing else could stop a worker thread's wait. Deadlines are on
141
- the monotonic clock, which a change to the system time doesn't move; `clock` is the wall
142
- clock that GitHub's reset times are read against."""
145
+ only the main thread, and nothing else could stop a worker thread's wait. They block for at
146
+ most WAIT_SLICE_SECONDS at a time, so that Ctrl-C on the main thread isn't held back on
147
+ Windows. Deadlines are on the monotonic clock, which a change to the system time doesn't
148
+ move; `clock` is the wall clock that GitHub's reset times are read against."""
143
149
 
144
150
  def __init__(
145
151
  self,
@@ -171,7 +177,11 @@ class RateLimitPause:
171
177
  def sleep(self, seconds: float) -> None:
172
178
  """Wait `seconds` without holding back other requests, unless cancel() comes first."""
173
179
  if seconds > 0 and not self.cancelled.is_set():
174
- self.cancelled.wait(seconds)
180
+ until = self.monotonic() + seconds
181
+ remaining = seconds
182
+ # a slice at a time, each measured against the deadline, so no lateness adds up
183
+ while remaining > 0 and not self.cancelled.wait(min(remaining, WAIT_SLICE_SECONDS)):
184
+ remaining = until - self.monotonic()
175
185
  self.raise_if_cancelled()
176
186
 
177
187
  def wait(self, seconds: float, **context: Any) -> None:
@@ -9,7 +9,7 @@ files weren't AI-reviewed. Ctrl-C stops the review at once: no request still que
9
9
  the replies already in are cached before the interrupt goes on.
10
10
  """
11
11
  from collections.abc import Callable
12
- from concurrent.futures import Future, ThreadPoolExecutor, as_completed
12
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
13
13
  from dataclasses import dataclass, field
14
14
 
15
15
  import structlog
@@ -21,6 +21,9 @@ from pr_analyzer.llm.prompts import SYSTEM_PROMPT
21
21
 
22
22
  log = structlog.get_logger(__name__)
23
23
 
24
+ # waits for parallel requests come in slices: on Windows one long wait holds Ctrl-C back until it ends
25
+ WAIT_SLICE_SECONDS = 0.5
26
+
24
27
 
25
28
  @dataclass
26
29
  class ReviewPlan:
@@ -80,15 +83,18 @@ def send(
80
83
  futures: dict[Future[str], int] = {
81
84
  pool.submit(ask, requests[index].prompt): index for index in indexes
82
85
  }
83
- for future in as_completed(futures):
84
- if future.cancelled():
85
- continue
86
- try:
87
- replies[futures[future]] = future.result()
88
- except Exception as e:
89
- error = error or e
90
- for pending in futures:
91
- pending.cancel() # only those not started yet; the others finish
86
+ pending = set(futures)
87
+ while pending:
88
+ done, pending = wait(pending, timeout=WAIT_SLICE_SECONDS, return_when=FIRST_COMPLETED)
89
+ for future in done:
90
+ if future.cancelled():
91
+ continue
92
+ try:
93
+ replies[futures[future]] = future.result()
94
+ except Exception as e:
95
+ error = error or e
96
+ for queued in pending:
97
+ queued.cancel() # only those not started yet; the others finish
92
98
  except BaseException:
93
99
  # Ctrl-C: the requests still queued are dropped rather than sent (each one is paid for),
94
100
  # and the ones in flight aren't waited for
@@ -46,6 +46,15 @@ RULE_IDS = frozenset(ast_analyzer.RULE_IDS + scanner.RULE_IDS + (LLM_RULE, CONFI
46
46
  KEYS = ("disable", "ignore_paths", "block_on", "inline_min_severity", "thresholds")
47
47
  THRESHOLD_KEYS = tuple(f.name for f in dataclasses.fields(Thresholds))
48
48
 
49
+ # Checked before anything else, since a problem that quoted such a value would fail to print it:
50
+ # Python won't turn an integer of over 4300 digits into text (tomllib reads one written in hex,
51
+ # octal or binary, and raises for a decimal one), nor print a value nested more deeply than the
52
+ # recursion limit. TOML's own integers are 64-bit, and no setting nests deeper than [thresholds].
53
+ _TOML_INTEGERS = range(-2**63, 2**63)
54
+ _MAX_NESTING = 100
55
+ _TOO_BIG = "the file has an integer outside TOML's 64-bit range"
56
+ _TOO_DEEP = f"the file nests tables or arrays more than {_MAX_NESTING} levels deep"
57
+
49
58
  # The part of .gitignore's syntax that ignore_paths supports, matched as git matches it: `*` and
50
59
  # `?` stay within one path segment, and `**` as a whole segment crosses directories (`**/` any
51
60
  # number of them, a final `/**` everything inside); any other run of asterisks is a `*`, and
@@ -67,6 +76,9 @@ _UNSUPPORTED = (
67
76
  (re.compile(r"[^/*]\*{2,}/[^/]"), "has ** inside a name before a /, which isn't supported: "
68
77
  "write foo*/bar for one directory, or foo*/**/bar for any "
69
78
  "number"),
79
+ # glob_regex drops every / at either end, where git drops one and then matches nothing; and
80
+ # in foo**//bar, which slips past the check above, git's ** crosses directories
81
+ (re.compile(r"//"), "has //, which isn't supported: write a single /"),
70
82
  )
71
83
 
72
84
 
@@ -129,7 +141,9 @@ def parse_project_config(raw: bytes | str, source: str = CONFIG_FILE) -> Project
129
141
 
130
142
  def read_project_config(raw: bytes | str) -> tuple[ProjectConfig, list[str]]:
131
143
  """The settings in a file and every problem with it. With any problem the settings are the
132
- defaults: the valid part of a broken file isn't what the project chose either."""
144
+ defaults: the valid part of a broken file isn't what the project chose either. A file that
145
+ can't be parsed, or holds an integer or nesting past the limits above, has only that problem:
146
+ its keys aren't checked."""
133
147
  try:
134
148
  text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
135
149
  data = tomllib.loads(text)
@@ -137,8 +151,17 @@ def read_project_config(raw: bytes | str) -> tuple[ProjectConfig, list[str]]:
137
151
  return ProjectConfig(), ["the file is not UTF-8 text"]
138
152
  except tomllib.TOMLDecodeError as e:
139
153
  return ProjectConfig(), [f"the file is not valid TOML: {e}"]
140
-
141
- problems: list[str] = []
154
+ except ValueError:
155
+ # int() refuses a decimal integer of over 4300 digits, and tomllib lets that through
156
+ return ProjectConfig(), [_TOO_BIG]
157
+ except RecursionError:
158
+ # tomllib reads nested arrays and inline tables recursively, and recent versions refuse
159
+ # a key with more parts than the recursion limit
160
+ return ProjectConfig(), [_TOO_DEEP]
161
+
162
+ problems = _limit_problems(data)
163
+ if problems:
164
+ return ProjectConfig(), problems
142
165
  values = _validate(data, problems)
143
166
  return (ProjectConfig() if problems else ProjectConfig(**values)), problems
144
167
 
@@ -172,6 +195,24 @@ def config_findings(text: str) -> list[Suggestion]:
172
195
  ]
173
196
 
174
197
 
198
+ def _limit_problems(data: dict[str, Any]) -> list[str]:
199
+ """_TOO_BIG and _TOO_DEEP, if the file has such a value. Walked without recursion, since the
200
+ nesting isn't known yet; the file itself is level 0, and a table or array in it level 1."""
201
+ found: set[str] = set()
202
+ pending: list[tuple[Any, int]] = [(data, 0)]
203
+ while pending:
204
+ value, level = pending.pop()
205
+ if isinstance(value, dict):
206
+ value = list(value.values())
207
+ if isinstance(value, list) and level > _MAX_NESTING:
208
+ found.add(_TOO_DEEP)
209
+ elif isinstance(value, list):
210
+ pending += [(item, level + 1) for item in value]
211
+ elif isinstance(value, int) and value not in _TOML_INTEGERS:
212
+ found.add(_TOO_BIG)
213
+ return sorted(found)
214
+
215
+
175
216
  def _validate(data: dict[str, Any], problems: list[str]) -> dict[str, Any]:
176
217
  problems += [unknown("key", key, KEYS) for key in data if key not in KEYS]
177
218
  values: dict[str, Any] = {}
File without changes