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.
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/PKG-INFO +11 -10
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/README.md +10 -9
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/pyproject.toml +1 -1
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/ast_analyzer.py +28 -6
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/line_lexer.py +6 -4
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/cache.py +109 -34
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/client.py +14 -6
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/markdown.py +10 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/ratelimit.py +14 -4
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/review.py +16 -10
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/project_config.py +44 -3
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/LICENSE +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/analyzer/scorer.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/cli.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/config.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/diff.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/exceptions.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/github/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/chunking.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/engine.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/llm/prompts.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/logging_config.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/models.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/pipeline.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/reporting/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/reporting/rich_reporter.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/__init__.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/dep_auditor.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/scanner.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/semgrep_runner.py +0 -0
- {pr_analyzer_cli-0.1.0 → pr_analyzer_cli-0.1.1}/src/pr_analyzer/security/tools.py +0 -0
- {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.
|
|
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
|
[](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml)
|
|
46
|
+
[](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
|
|
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,
|
|
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
|
|
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
|
[](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
|
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,
|
|
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
|
|
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.
|
|
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 "
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
-
|
|
310
|
-
|
|
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
|
|
231
|
-
|
|
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
|
|
15
|
-
<dir>/v1 are created private to the user (PRIVATE_MODE), and before either is used it
|
|
16
|
-
real directory (not a symbolic link) that belongs to the user: one of the user's that
|
|
17
|
-
use too is made private, and anything else turns the cache off for the run, with a
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
self._unsafe = False #
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
stamp
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
391
|
+
info = os.lstat(self.root / PRUNE_STAMP)
|
|
324
392
|
except OSError:
|
|
325
393
|
return True
|
|
326
|
-
|
|
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) ->
|
|
359
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
141
|
-
|
|
142
|
-
clock
|
|
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.
|
|
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,
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|