prism-code-review 2.0.0__tar.gz → 2.0.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.
- {prism_code_review-2.0.0/prism_code_review.egg-info → prism_code_review-2.0.1}/PKG-INFO +29 -16
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/README.md +28 -15
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/__init__.py +1 -1
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/autofix.py +37 -1
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_commands.py +6 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_reports.py +32 -2
- prism_code_review-2.0.1/prism/dashboard.html +343 -0
- prism_code_review-2.0.1/prism/pre-commit-hook.sh +37 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/reports.py +46 -1
- {prism_code_review-2.0.0 → prism_code_review-2.0.1/prism_code_review.egg-info}/PKG-INFO +29 -16
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/SOURCES.txt +2 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/pyproject.toml +7 -1
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_regressions.py +85 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/LICENSE +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/__main__.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_policy.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_quality.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_security.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_test.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_architecture.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_common.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_findings.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_graph.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_performance.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_pipeline.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_policy.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_providers.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_quality.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_security.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_test.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_config.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_review.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/codebase_cache.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_common.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_setup.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/feedback.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ignore.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/langs.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/logging_config.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/lsp.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/modes.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/token_usage.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/dependency_links.txt +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/entry_points.txt +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/requires.txt +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/top_level.txt +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/setup.cfg +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_autofix_languages.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_call_graph.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_cli_handlers.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_coverage_paths.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_global_key.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_golden_corpus.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_integration.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_languages.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_lsp.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_product.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_product_e2e.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_providers.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_security.py +0 -0
- {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_token_reporting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prism-code-review
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Local pre-commit multi-agent code review (regex + AI providers)
|
|
5
5
|
Author: PRISM contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -197,21 +197,12 @@ There are only three SDKs to install. The five OpenAI-compatible providers
|
|
|
197
197
|
|
|
198
198
|
### Hand the tool to a teammate (no `cd`, no venv on their side)
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
**one** command from any folder — no virtualenv, no source checkout, no build
|
|
202
|
-
tools:
|
|
200
|
+
One command from any folder — no virtualenv, no source checkout, no build tools:
|
|
203
201
|
|
|
204
202
|
```bash
|
|
205
|
-
# On the machine that has the repo (you):
|
|
206
|
-
py -m build # -> dist/prism_code_review-2.0.0-py3-none-any.whl
|
|
207
|
-
# then: GitHub -> Releases -> Draft a new release -> tag v2.0.0
|
|
208
|
-
# -> attach the .whl -> Publish
|
|
209
|
-
|
|
210
203
|
# On the teammate's machine, once. The [all] extra installs every provider SDK
|
|
211
204
|
# plus python-dotenv; without an extra the regex agents run but AI agents skip:
|
|
212
|
-
|
|
213
|
-
# extras that are appended straight onto a direct wheel URL)
|
|
214
|
-
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
205
|
+
pip install "prism-code-review[all]"
|
|
215
206
|
|
|
216
207
|
# Set the API key once for every project (tool-wide):
|
|
217
208
|
prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
@@ -219,6 +210,18 @@ prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
|
219
210
|
# Then in any project folder: git init, prism init, prism check ...
|
|
220
211
|
```
|
|
221
212
|
|
|
213
|
+
No PyPI access (air-gapped machine, internal mirror)? Install straight from the
|
|
214
|
+
GitHub Release wheel instead. Note the `name[extras] @ url` form — pip does not
|
|
215
|
+
parse extras that are appended directly onto a wheel URL, so `...whl[all]`
|
|
216
|
+
returns a 404:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Both routes give the same package. To cut a new release, see
|
|
223
|
+
[Releasing](#releasing).
|
|
224
|
+
|
|
222
225
|
Use `[all]` when you do not control which provider each developer will use. For
|
|
223
226
|
a team standardised on one provider, install only that extra — `[gemini]`,
|
|
224
227
|
`[claude]`, or `[openai]` (the last also covers openrouter, groq, together,
|
|
@@ -1380,12 +1383,22 @@ The suite covers:
|
|
|
1380
1383
|
## Releasing
|
|
1381
1384
|
|
|
1382
1385
|
```bash
|
|
1383
|
-
py -m build
|
|
1386
|
+
py -m build # -> dist/prism_code_review-<version>-*.whl and .tar.gz
|
|
1387
|
+
py -m twine check dist/* # fails the upload if the README does not render
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
Then publish to both places:
|
|
1391
|
+
|
|
1392
|
+
```bash
|
|
1393
|
+
# PyPI (the short install path)
|
|
1394
|
+
py -m twine upload dist/*
|
|
1395
|
+
|
|
1396
|
+
# GitHub (the wheel-only fallback for machines without PyPI access)
|
|
1397
|
+
#Releases -> Draft a new release -> tag v<version> -> attach the .whl -> Publish
|
|
1384
1398
|
```
|
|
1385
1399
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
wheel is never committed; the Release asset URL is what developers install from.
|
|
1400
|
+
`dist/` is git-ignored, so the wheel is never committed — the Release asset and
|
|
1401
|
+
the PyPI project are what developers install from.
|
|
1389
1402
|
|
|
1390
1403
|
The wheel declares no hard dependencies — providers are opt-in extras
|
|
1391
1404
|
(`[gemini]`, `[claude]`, `[openai]`, `[all]`, `[dev]`), so a base install stays
|
|
@@ -157,21 +157,12 @@ There are only three SDKs to install. The five OpenAI-compatible providers
|
|
|
157
157
|
|
|
158
158
|
### Hand the tool to a teammate (no `cd`, no venv on their side)
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
**one** command from any folder — no virtualenv, no source checkout, no build
|
|
162
|
-
tools:
|
|
160
|
+
One command from any folder — no virtualenv, no source checkout, no build tools:
|
|
163
161
|
|
|
164
162
|
```bash
|
|
165
|
-
# On the machine that has the repo (you):
|
|
166
|
-
py -m build # -> dist/prism_code_review-2.0.0-py3-none-any.whl
|
|
167
|
-
# then: GitHub -> Releases -> Draft a new release -> tag v2.0.0
|
|
168
|
-
# -> attach the .whl -> Publish
|
|
169
|
-
|
|
170
163
|
# On the teammate's machine, once. The [all] extra installs every provider SDK
|
|
171
164
|
# plus python-dotenv; without an extra the regex agents run but AI agents skip:
|
|
172
|
-
|
|
173
|
-
# extras that are appended straight onto a direct wheel URL)
|
|
174
|
-
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
165
|
+
pip install "prism-code-review[all]"
|
|
175
166
|
|
|
176
167
|
# Set the API key once for every project (tool-wide):
|
|
177
168
|
prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
@@ -179,6 +170,18 @@ prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
|
179
170
|
# Then in any project folder: git init, prism init, prism check ...
|
|
180
171
|
```
|
|
181
172
|
|
|
173
|
+
No PyPI access (air-gapped machine, internal mirror)? Install straight from the
|
|
174
|
+
GitHub Release wheel instead. Note the `name[extras] @ url` form — pip does not
|
|
175
|
+
parse extras that are appended directly onto a wheel URL, so `...whl[all]`
|
|
176
|
+
returns a 404:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Both routes give the same package. To cut a new release, see
|
|
183
|
+
[Releasing](#releasing).
|
|
184
|
+
|
|
182
185
|
Use `[all]` when you do not control which provider each developer will use. For
|
|
183
186
|
a team standardised on one provider, install only that extra — `[gemini]`,
|
|
184
187
|
`[claude]`, or `[openai]` (the last also covers openrouter, groq, together,
|
|
@@ -1340,12 +1343,22 @@ The suite covers:
|
|
|
1340
1343
|
## Releasing
|
|
1341
1344
|
|
|
1342
1345
|
```bash
|
|
1343
|
-
py -m build
|
|
1346
|
+
py -m build # -> dist/prism_code_review-<version>-*.whl and .tar.gz
|
|
1347
|
+
py -m twine check dist/* # fails the upload if the README does not render
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
Then publish to both places:
|
|
1351
|
+
|
|
1352
|
+
```bash
|
|
1353
|
+
# PyPI (the short install path)
|
|
1354
|
+
py -m twine upload dist/*
|
|
1355
|
+
|
|
1356
|
+
# GitHub (the wheel-only fallback for machines without PyPI access)
|
|
1357
|
+
#Releases -> Draft a new release -> tag v<version> -> attach the .whl -> Publish
|
|
1344
1358
|
```
|
|
1345
1359
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
wheel is never committed; the Release asset URL is what developers install from.
|
|
1360
|
+
`dist/` is git-ignored, so the wheel is never committed — the Release asset and
|
|
1361
|
+
the PyPI project are what developers install from.
|
|
1349
1362
|
|
|
1350
1363
|
The wheel declares no hard dependencies — providers are opt-in extras
|
|
1351
1364
|
(`[gemini]`, `[claude]`, `[openai]`, `[all]`, `[dev]`), so a base install stays
|
|
@@ -12,6 +12,7 @@ from typing import List, Optional, Tuple
|
|
|
12
12
|
from prism.agents import Finding
|
|
13
13
|
from prism.langs import lang_for
|
|
14
14
|
from prism.logging_config import logger
|
|
15
|
+
from prism.reports import looks_like_secret
|
|
15
16
|
|
|
16
17
|
# A declaration is a name preceded by modifiers, a type, or a qualifier
|
|
17
18
|
# (`const token`, `String apiKey`, `self.password`). Taking the name from the
|
|
@@ -23,6 +24,13 @@ _SECRET_ASSIGN = re.compile(
|
|
|
23
24
|
r'(?P<op>\s*(?::=|(?<![=!<>:])=(?!=)|:(?!=))\s*)'
|
|
24
25
|
r'(?P<q>["\'])(?P<lit>.*?)(?P=q)(?P<tail>.*)$')
|
|
25
26
|
|
|
27
|
+
# The unquoted counterpart: `PASSWORD=hunter2`, `export TOKEN=abc`,
|
|
28
|
+
# `#define API_KEY abc123`. Kept separate from _SECRET_ASSIGN so the value is
|
|
29
|
+
# masked rather than copied into a suggested fix.
|
|
30
|
+
_UNQUOTED_ASSIGN = re.compile(
|
|
31
|
+
r'^(?P<head>\s*(?:export\s+|#define\s+)?[\w$.\[\]]+\s*'
|
|
32
|
+
r'(?::=|(?<![=!<>:])=(?!=)|:(?!=))\s*)(?P<val>.*)$')
|
|
33
|
+
|
|
26
34
|
|
|
27
35
|
def _is_valid_python(source: str) -> bool:
|
|
28
36
|
"""True when source parses as a complete Python module."""
|
|
@@ -143,7 +151,7 @@ def _remove_line(lines: list, idx: int) -> str:
|
|
|
143
151
|
return ""
|
|
144
152
|
|
|
145
153
|
|
|
146
|
-
def _replace_secret(line: str, finding: Finding, spec=None) -> str:
|
|
154
|
+
def _replace_secret(line: str, finding: Finding, spec=None) -> Optional[str]:
|
|
147
155
|
"""Replace a hardcoded secret with a read from the environment.
|
|
148
156
|
|
|
149
157
|
The declaration is left as it is and only the literal is swapped, so
|
|
@@ -151,10 +159,23 @@ def _replace_secret(line: str, finding: Finding, spec=None) -> str:
|
|
|
151
159
|
becomes the language's own lookup (``os.Getenv("APIKEY")`` in Go,
|
|
152
160
|
``process.env.APIKEY`` in JS). Emitting ``os.getenv`` into a Go file would
|
|
153
161
|
not compile, which is worse than offering no fix at all.
|
|
162
|
+
|
|
163
|
+
Returns None when the line cannot be rewritten safely: the suggestion is
|
|
164
|
+
rendered in the dashboard and the JSON report, so echoing the original line
|
|
165
|
+
back would copy the live credential into a file that is meant to be shared.
|
|
154
166
|
"""
|
|
155
167
|
env_read = spec.env_read if spec is not None else 'os.getenv("{name}")'
|
|
156
168
|
match = _SECRET_ASSIGN.match(line)
|
|
157
169
|
if not match:
|
|
170
|
+
# Not a quoted declaration. Three cases, and only one of them is safe
|
|
171
|
+
# to echo back:
|
|
172
|
+
# - an unquoted assignment (SECU-123/125) -> mask the value
|
|
173
|
+
# - a credential embedded in some other expression -> offer no fix
|
|
174
|
+
# - no credential at all (a comparison, a call) -> the old no-op
|
|
175
|
+
if _UNQUOTED_ASSIGN.match(line):
|
|
176
|
+
return _mask_secret_value(line)
|
|
177
|
+
if looks_like_secret(line):
|
|
178
|
+
return None
|
|
158
179
|
return line
|
|
159
180
|
# PHP declares `$key`; the sigil is not part of the variable's name.
|
|
160
181
|
name = re.sub(r"[^A-Za-z0-9]", "_", match.group("name").lstrip("$")).upper()
|
|
@@ -162,6 +183,21 @@ def _replace_secret(line: str, finding: Finding, spec=None) -> str:
|
|
|
162
183
|
+ env_read.format(name=name) + match.group("tail"))
|
|
163
184
|
|
|
164
185
|
|
|
186
|
+
def _mask_secret_value(line: str) -> Optional[str]:
|
|
187
|
+
"""Mask the value of an unquoted secret assignment.
|
|
188
|
+
|
|
189
|
+
SECU-123/125 match dotenv-style (``PASSWORD=hunter2``) and shell-export
|
|
190
|
+
(``export TOKEN=abc``) secrets, which have no quoted literal for
|
|
191
|
+
:data:`_SECRET_ASSIGN` to swap. Returning the line unchanged put the live
|
|
192
|
+
credential into the report, and the dashboard renders ``suggested_code``
|
|
193
|
+
verbatim. Masking keeps the fix useful without copying the secret.
|
|
194
|
+
"""
|
|
195
|
+
match = _UNQUOTED_ASSIGN.match(line)
|
|
196
|
+
if not match:
|
|
197
|
+
return None
|
|
198
|
+
return match.group("head") + '"***REDACTED***"'
|
|
199
|
+
|
|
200
|
+
|
|
165
201
|
def _remove_dangerous_call(line: str, name: str, spec=None) -> str:
|
|
166
202
|
"""Replace a dangerous call with a syntactically valid, inert statement.
|
|
167
203
|
|
|
@@ -586,6 +586,8 @@ def _cmd_check_diff(args):
|
|
|
586
586
|
|
|
587
587
|
base = getattr(args, "base", None) or os.getenv("PRISM_BASE") or "origin/main"
|
|
588
588
|
|
|
589
|
+
requested_base = base
|
|
590
|
+
|
|
589
591
|
def _ref_ok(ref):
|
|
590
592
|
try:
|
|
591
593
|
return subprocess.run(
|
|
@@ -603,6 +605,10 @@ def _cmd_check_diff(args):
|
|
|
603
605
|
else:
|
|
604
606
|
print("Cannot resolve base ref '{}'".format(base), file=sys.stderr)
|
|
605
607
|
sys.exit(2)
|
|
608
|
+
# A typo'd branch used to fall through to HEAD~1 in silence, so the
|
|
609
|
+
# review looked like it covered the requested ref. Say which one won.
|
|
610
|
+
_status("Base ref '{}' not found - reviewing against '{}' instead".format(
|
|
611
|
+
requested_base, base))
|
|
606
612
|
|
|
607
613
|
files = [f for f in get_diff_files(base) if Path(f).exists()]
|
|
608
614
|
# check-staged honours .prismignore (get_staged_files applies it); check-diff
|
|
@@ -12,6 +12,8 @@ from prism.codebase_cache import get_changed_files, get_cache_stats, clear_cache
|
|
|
12
12
|
from prism.feedback import load_feedback, suppress_summary
|
|
13
13
|
from prism.reports import load_report, delete_report, clear_reports, get_report_summary
|
|
14
14
|
|
|
15
|
+
VALID_SEVERITIES = ("critical", "high", "medium", "low")
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
def _cmd_reports(args):
|
|
17
19
|
print(get_report_summary())
|
|
@@ -127,8 +129,13 @@ def open_dashboard(url):
|
|
|
127
129
|
|
|
128
130
|
|
|
129
131
|
def _cmd_dashboard(args):
|
|
130
|
-
"""Generate the dashboard and open it in the browser.
|
|
131
|
-
|
|
132
|
+
"""Generate the dashboard and open it in the browser.
|
|
133
|
+
|
|
134
|
+
A missing template is a packaging failure, not an empty report: exit
|
|
135
|
+
non-zero so a script running `prism dashboard` does not read it as success.
|
|
136
|
+
"""
|
|
137
|
+
if not build_dashboard(open_browser=True):
|
|
138
|
+
sys.exit(1)
|
|
132
139
|
|
|
133
140
|
|
|
134
141
|
def _cmd_stats(args):
|
|
@@ -159,6 +166,24 @@ def _set_config_value(config, dotted, value):
|
|
|
159
166
|
node[parts[-1]] = value
|
|
160
167
|
|
|
161
168
|
|
|
169
|
+
def _validate_config_value(key, value):
|
|
170
|
+
"""Return an error message for a value that would silently change behaviour.
|
|
171
|
+
|
|
172
|
+
``block_on`` drives the quality gate. Written as a bare string it still
|
|
173
|
+
loads, but the membership test becomes a substring test, so every commit
|
|
174
|
+
passes while the config looks correct. Refuse the write instead.
|
|
175
|
+
"""
|
|
176
|
+
if key == "block_on":
|
|
177
|
+
if not isinstance(value, list):
|
|
178
|
+
return 'block_on must be a list, e.g. ["critical", "high"]'
|
|
179
|
+
unknown = [v for v in value
|
|
180
|
+
if not isinstance(v, str) or v.lower() not in VALID_SEVERITIES]
|
|
181
|
+
if unknown:
|
|
182
|
+
return "block_on has unknown severity value(s): {} (valid: {})".format(
|
|
183
|
+
", ".join(repr(v) for v in unknown), ", ".join(VALID_SEVERITIES))
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
162
187
|
def _cmd_config(args):
|
|
163
188
|
config = load_config()
|
|
164
189
|
if getattr(args, "set", None):
|
|
@@ -167,6 +192,11 @@ def _cmd_config(args):
|
|
|
167
192
|
value = json.loads(value)
|
|
168
193
|
except json.JSONDecodeError:
|
|
169
194
|
pass
|
|
195
|
+
problem = _validate_config_value(key, value)
|
|
196
|
+
if problem:
|
|
197
|
+
print("Invalid value for {}: {}".format(key, problem), file=sys.stderr)
|
|
198
|
+
print("No changes written.", file=sys.stderr)
|
|
199
|
+
sys.exit(1)
|
|
170
200
|
_set_config_value(config, key, value)
|
|
171
201
|
# Write back to the file we read from, not always prism.json.
|
|
172
202
|
config_path = get_config_path()
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>PRISM Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
|
|
10
|
+
h1 { font-size: 28px; margin-bottom: 8px; color: #38bdf8; }
|
|
11
|
+
h2 { font-size: 18px; margin: 24px 0 12px; color: #cbd5e1; }
|
|
12
|
+
.container { max-width: 1280px; margin: 0 auto; }
|
|
13
|
+
|
|
14
|
+
/* Stat cards */
|
|
15
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
16
|
+
.stat-card { background: #1e293b; border-radius: 12px; padding: 20px; text-align: center; border: 1px solid #334155; }
|
|
17
|
+
.stat-value { font-size: 36px; font-weight: bold; }
|
|
18
|
+
.stat-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
|
19
|
+
|
|
20
|
+
/* Charts */
|
|
21
|
+
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
|
22
|
+
.chart-card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
|
23
|
+
.chart-title { font-size: 14px; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
24
|
+
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
25
|
+
.bar-label { width: 130px; font-size: 13px; color: #cbd5e1; text-align: right; flex-shrink: 0; }
|
|
26
|
+
.bar-track { flex: 1; height: 22px; background: #0f172a; border-radius: 6px; overflow: hidden; position: relative; }
|
|
27
|
+
.bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s; }
|
|
28
|
+
.bar-value { width: 40px; font-size: 13px; font-weight: bold; text-align: right; flex-shrink: 0; }
|
|
29
|
+
.sev-critical { background: #ef4444; }
|
|
30
|
+
.sev-high { background: #f97316; }
|
|
31
|
+
.sev-medium { background: #eab308; }
|
|
32
|
+
.sev-low { background: #22c55e; }
|
|
33
|
+
.agent-security { background: #8b5cf6; }
|
|
34
|
+
.agent-quality { background: #3b82f6; }
|
|
35
|
+
.agent-policy { background: #06b6d4; }
|
|
36
|
+
.agent-test { background: #10b981; }
|
|
37
|
+
.agent-ai { background: #ec4899; }
|
|
38
|
+
.token-fill { background: #a78bfa; }
|
|
39
|
+
.bar-value.wide { width: 76px; }
|
|
40
|
+
.bar-label.token-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
41
|
+
|
|
42
|
+
/* Filters */
|
|
43
|
+
.filters { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
|
44
|
+
.filter-label { font-size: 13px; color: #94a3b8; margin-right: 4px; }
|
|
45
|
+
.chip { padding: 6px 14px; border-radius: 20px; border: 1px solid #334155; background: #1e293b; color: #cbd5e1; font-size: 13px; cursor: pointer; transition: all 0.15s; }
|
|
46
|
+
.chip:hover { border-color: #38bdf8; }
|
|
47
|
+
.chip.active { background: #38bdf8; color: #0f172a; border-color: #38bdf8; font-weight: bold; }
|
|
48
|
+
|
|
49
|
+
/* Timeline */
|
|
50
|
+
.timeline { position: relative; padding-left: 30px; }
|
|
51
|
+
.timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 2px; background: #334155; }
|
|
52
|
+
.report-item { position: relative; margin-bottom: 20px; padding: 16px; background: #1e293b; border-radius: 10px; border: 1px solid #334155; cursor: pointer; transition: border-color 0.15s; }
|
|
53
|
+
.report-item:hover { border-color: #38bdf8; }
|
|
54
|
+
.report-item.expanded { border-color: #38bdf8; }
|
|
55
|
+
.report-item::before { content: ''; position: absolute; left: -26px; top: 20px; width: 12px; height: 12px; border-radius: 50%; background: #60a5fa; }
|
|
56
|
+
.report-item.allowed::before { background: #22c55e; }
|
|
57
|
+
.report-item.blocked::before { background: #ef4444; }
|
|
58
|
+
.report-date { font-size: 13px; color: #94a3b8; }
|
|
59
|
+
.report-files { font-size: 14px; margin: 8px 0; color: #cbd5e1; }
|
|
60
|
+
.report-risk { font-size: 18px; font-weight: bold; }
|
|
61
|
+
.report-meta { display: flex; gap: 16px; margin-top: 8px; font-size: 13px; color: #94a3b8; }
|
|
62
|
+
.badge { display: inline-block; padding: 2px 10px; border-radius: 6px; font-size: 12px; font-weight: bold; }
|
|
63
|
+
.badge-pass { background: #22c55e; color: #052e16; }
|
|
64
|
+
.badge-block { background: #ef4444; color: #fff; }
|
|
65
|
+
|
|
66
|
+
/* Severity bar */
|
|
67
|
+
.severity-bar { height: 8px; border-radius: 4px; margin: 8px 0; display: flex; gap: 2px; }
|
|
68
|
+
.severity-bar div { height: 100%; border-radius: 4px; }
|
|
69
|
+
|
|
70
|
+
/* Detail view */
|
|
71
|
+
.detail-panel { margin-top: 14px; padding-top: 14px; border-top: 1px solid #334155; display: none; }
|
|
72
|
+
.report-item.expanded .detail-panel { display: block; }
|
|
73
|
+
.finding-item { padding: 10px 12px; margin-bottom: 8px; background: #0f172a; border-radius: 8px; border-left: 3px solid #334155; font-size: 13px; }
|
|
74
|
+
.finding-item.critical { border-left-color: #ef4444; }
|
|
75
|
+
.finding-item.high { border-left-color: #f97316; }
|
|
76
|
+
.finding-item.medium { border-left-color: #eab308; }
|
|
77
|
+
.finding-item.low { border-left-color: #22c55e; }
|
|
78
|
+
.finding-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
|
79
|
+
.finding-severity { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }
|
|
80
|
+
.finding-rule { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; color: #38bdf8; }
|
|
81
|
+
.finding-msg { color: #e2e8f0; margin: 4px 0; }
|
|
82
|
+
.finding-loc { color: #64748b; font-size: 12px; }
|
|
83
|
+
.finding-suggestion { color: #94a3b8; margin-top: 4px; font-style: italic; }
|
|
84
|
+
.finding-code { background: #020617; padding: 8px; border-radius: 6px; margin-top: 6px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; color: #4ade80; overflow-x: auto; white-space: pre; }
|
|
85
|
+
.finding-code-head { margin-top: 8px; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: #64748b; }
|
|
86
|
+
.finding-conf { color: #64748b; font-size: 11px; }
|
|
87
|
+
.finding-also { font-size: 11px; color: #a78bfa; background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 1px 6px; }
|
|
88
|
+
.no-findings { color: #22c55e; font-size: 13px; padding: 8px 0; }
|
|
89
|
+
.empty { text-align: center; padding: 40px; color: #64748b; }
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div class="container">
|
|
94
|
+
<h1>PRISM Report Dashboard</h1>
|
|
95
|
+
<div id="reports">Loading...</div>
|
|
96
|
+
</div>
|
|
97
|
+
<script>
|
|
98
|
+
/* PRISM_DASHBOARD_DATA */
|
|
99
|
+
function loadReports() {
|
|
100
|
+
render({reports: (typeof EMBEDDED_REPORTS !== 'undefined' ? EMBEDDED_REPORTS : [])});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function render(data) {
|
|
104
|
+
const reports = (data.reports || []).slice().reverse();
|
|
105
|
+
if (!reports.length) { document.getElementById('reports').innerHTML = '<p class="empty">No reports found. Run <code>prism check</code> to generate one.</p>'; return; }
|
|
106
|
+
|
|
107
|
+
// Aggregate stats
|
|
108
|
+
const total = reports.length;
|
|
109
|
+
const blocked = reports.filter(r => !r.commit_allowed).length;
|
|
110
|
+
const findings = reports.reduce((s, r) => s + (r.total_findings || 0), 0);
|
|
111
|
+
const blocking = reports.reduce((s, r) => s + (r.blocking || 0), 0);
|
|
112
|
+
|
|
113
|
+
// Token accounting. Reports written before token tracking existed have no
|
|
114
|
+
// token_usage field: they contribute nothing here and render as unknown in
|
|
115
|
+
// the timeline, so a cost trend never dips because of missing history.
|
|
116
|
+
const tokenReports = reports.filter(r => r.token_usage);
|
|
117
|
+
const tokens = tokenReports.reduce((s, r) => s + (r.token_usage.total_tokens || 0), 0);
|
|
118
|
+
const tokenCalls = tokenReports.reduce((s, r) => s + (r.token_usage.calls || 0), 0);
|
|
119
|
+
const estimatedCalls = tokenReports.reduce((s, r) => s + (r.token_usage.estimated_calls || 0), 0);
|
|
120
|
+
|
|
121
|
+
// Severity counts
|
|
122
|
+
const sevCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
|
123
|
+
// Agent counts
|
|
124
|
+
const agentCounts = {};
|
|
125
|
+
// Rule counts
|
|
126
|
+
const ruleCounts = {};
|
|
127
|
+
|
|
128
|
+
reports.forEach(r => {
|
|
129
|
+
(r.findings || []).forEach(f => {
|
|
130
|
+
const sev = (f.severity || 'low').toLowerCase();
|
|
131
|
+
if (sevCounts[sev] !== undefined) sevCounts[sev]++;
|
|
132
|
+
const agent = f.agent || 'unknown';
|
|
133
|
+
agentCounts[agent] = (agentCounts[agent] || 0) + 1;
|
|
134
|
+
if (f.rule_id) ruleCounts[f.rule_id] = (ruleCounts[f.rule_id] || 0) + 1;
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let html = '';
|
|
139
|
+
|
|
140
|
+
// Stat cards
|
|
141
|
+
html += '<div class="stats">';
|
|
142
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#38bdf8">' + total + '</div><div class="stat-label">Total Reviews</div></div>';
|
|
143
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#ef4444">' + blocked + '</div><div class="stat-label">Blocked</div></div>';
|
|
144
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#22c55e">' + (total - blocked) + '</div><div class="stat-label">Passed</div></div>';
|
|
145
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#f97316">' + findings + '</div><div class="stat-label">Total Findings</div></div>';
|
|
146
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#eab308">' + blocking + '</div><div class="stat-label">Blocking</div></div>';
|
|
147
|
+
html += '<div class="stat-card"><div class="stat-value" style="color:#a78bfa">' + tokens.toLocaleString() + '</div><div class="stat-label">Tokens (' + tokenCalls + ' call' + (tokenCalls === 1 ? '' : 's') + (estimatedCalls ? ', ' + estimatedCalls + ' est.' : '') + ')</div></div>';
|
|
148
|
+
html += '</div>';
|
|
149
|
+
|
|
150
|
+
// Charts row
|
|
151
|
+
html += '<div class="charts">';
|
|
152
|
+
|
|
153
|
+
// Severity chart
|
|
154
|
+
const sevMax = Math.max(1, ...Object.values(sevCounts));
|
|
155
|
+
html += '<div class="chart-card"><div class="chart-title">Findings by Severity</div>';
|
|
156
|
+
['critical', 'high', 'medium', 'low'].forEach(sev => {
|
|
157
|
+
const c = sevCounts[sev] || 0;
|
|
158
|
+
const pct = (c / sevMax * 100).toFixed(0);
|
|
159
|
+
html += '<div class="bar-row"><div class="bar-label">' + sev.toUpperCase() + '</div>';
|
|
160
|
+
html += '<div class="bar-track"><div class="bar-fill sev-' + sev + '" style="width:' + pct + '%"></div></div>';
|
|
161
|
+
html += '<div class="bar-value">' + c + '</div></div>';
|
|
162
|
+
});
|
|
163
|
+
html += '</div>';
|
|
164
|
+
|
|
165
|
+
// Agent chart
|
|
166
|
+
const agents = Object.entries(agentCounts).sort((a, b) => b[1] - a[1]);
|
|
167
|
+
const agentMax = Math.max(1, ...agents.map(a => a[1]));
|
|
168
|
+
html += '<div class="chart-card"><div class="chart-title">Findings by Agent</div>';
|
|
169
|
+
if (!agents.length) {
|
|
170
|
+
html += '<div class="empty" style="padding:12px">No findings</div>';
|
|
171
|
+
} else {
|
|
172
|
+
agents.forEach(([agent, c]) => {
|
|
173
|
+
const pct = (c / agentMax * 100).toFixed(0);
|
|
174
|
+
const cls = agent.startsWith('ai_') ? 'agent-ai' : 'agent-' + agent;
|
|
175
|
+
html += '<div class="bar-row"><div class="bar-label">' + agent + '</div>';
|
|
176
|
+
html += '<div class="bar-track"><div class="bar-fill ' + cls + '" style="width:' + pct + '%"></div></div>';
|
|
177
|
+
html += '<div class="bar-value">' + c + '</div></div>';
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
html += '</div>';
|
|
181
|
+
|
|
182
|
+
// Token chart: one bar per review that actually spent tokens.
|
|
183
|
+
const tokenSeries = tokenReports.filter(r => r.token_usage.calls)
|
|
184
|
+
.slice(0, 12).reverse();
|
|
185
|
+
html += '<div class="chart-card"><div class="chart-title">Tokens by Review</div>';
|
|
186
|
+
if (!tokenSeries.length) {
|
|
187
|
+
html += '<div class="empty" style="padding:12px">No AI usage recorded</div>';
|
|
188
|
+
} else {
|
|
189
|
+
const tokenMax = Math.max(1, ...tokenSeries.map(r => r.token_usage.total_tokens || 0));
|
|
190
|
+
tokenSeries.forEach(r => {
|
|
191
|
+
const value = r.token_usage.total_tokens || 0;
|
|
192
|
+
const pct = (value / tokenMax * 100).toFixed(0);
|
|
193
|
+
const when = new Date(r.timestamp).toLocaleDateString();
|
|
194
|
+
const rev = r.commit_hash ? r.commit_hash.substring(0, 7) : 'no-commit';
|
|
195
|
+
html += '<div class="bar-row"><div class="bar-label token-label" title="' + escapeHtml(when + ' ' + rev) + '">' + escapeHtml(when + ' ' + rev) + '</div>';
|
|
196
|
+
html += '<div class="bar-track"><div class="bar-fill token-fill" style="width:' + pct + '%"></div></div>';
|
|
197
|
+
html += '<div class="bar-value wide">' + value.toLocaleString() + '</div></div>';
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
html += '</div>';
|
|
201
|
+
html += '</div>';
|
|
202
|
+
|
|
203
|
+
// Filters
|
|
204
|
+
html += '<div class="filters">';
|
|
205
|
+
html += '<span class="filter-label">Filter:</span>';
|
|
206
|
+
html += '<span class="chip active" data-filter="all">All (' + total + ')</span>';
|
|
207
|
+
html += '<span class="chip" data-filter="blocked">Blocked (' + blocked + ')</span>';
|
|
208
|
+
html += '<span class="chip" data-filter="passed">Passed (' + (total - blocked) + ')</span>';
|
|
209
|
+
html += '</div>';
|
|
210
|
+
|
|
211
|
+
// Timeline
|
|
212
|
+
html += '<div class="timeline" id="timeline">';
|
|
213
|
+
reports.forEach((r, idx) => {
|
|
214
|
+
const cls = r.commit_allowed ? 'allowed' : 'blocked';
|
|
215
|
+
const date = new Date(r.timestamp).toLocaleDateString() + ' ' + new Date(r.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
216
|
+
const badgeCls = r.commit_allowed ? 'badge-pass' : 'badge-block';
|
|
217
|
+
const badgeTxt = r.commit_allowed ? 'PASS' : 'BLOCKED';
|
|
218
|
+
|
|
219
|
+
// Severity bar for this report
|
|
220
|
+
const rSev = {critical: 0, high: 0, medium: 0, low: 0};
|
|
221
|
+
(r.findings || []).forEach(f => {
|
|
222
|
+
const sev = (f.severity || 'low').toLowerCase();
|
|
223
|
+
if (rSev[sev] !== undefined) rSev[sev]++;
|
|
224
|
+
});
|
|
225
|
+
const rTotal = rSev.critical + rSev.high + rSev.medium + rSev.low;
|
|
226
|
+
let sevBar = '<div class="severity-bar">';
|
|
227
|
+
if (rTotal > 0) {
|
|
228
|
+
['critical', 'high', 'medium', 'low'].forEach(sev => {
|
|
229
|
+
if (rSev[sev] > 0) {
|
|
230
|
+
const w = (rSev[sev] / rTotal * 100).toFixed(0);
|
|
231
|
+
sevBar += '<div class="sev-' + sev + '" style="width:' + w + '%" title="' + sev + ': ' + rSev[sev] + '"></div>';
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
sevBar += '</div>';
|
|
236
|
+
|
|
237
|
+
html += '<div class="report-item ' + cls + '" data-idx="' + idx + '" data-status="' + (r.commit_allowed ? 'passed' : 'blocked') + '">';
|
|
238
|
+
html += '<div class="report-date">' + date + ' · ' + (r.branch || 'main') + ' · ' + (r.commit_hash ? r.commit_hash.substring(0,7) : 'no-commit') + '</div>';
|
|
239
|
+
html += '<div class="report-risk">Risk: ' + Math.round((r.risk_score || 0) * 100) + '% <span class="badge ' + badgeCls + '">' + badgeTxt + '</span></div>';
|
|
240
|
+
html += '<div class="report-files">' + (r.files_reviewed || []).join(', ') + '</div>';
|
|
241
|
+
html += sevBar;
|
|
242
|
+
html += '<div class="report-meta"><span>' + (r.total_findings || 0) + ' findings</span><span>' + (r.blocking || 0) + ' blocking</span><span' + (r.token_usage ? '' : ' title="report predates token tracking"') + '>' + tokenLabel(r) + '</span><span>Click for details</span></div>';
|
|
243
|
+
|
|
244
|
+
// Detail panel (per-finding)
|
|
245
|
+
html += '<div class="detail-panel">';
|
|
246
|
+
html += tokenDetail(r);
|
|
247
|
+
const findingsList = r.findings || [];
|
|
248
|
+
if (!findingsList.length) {
|
|
249
|
+
html += '<div class="no-findings">No findings - clean commit</div>';
|
|
250
|
+
} else {
|
|
251
|
+
findingsList.forEach(f => {
|
|
252
|
+
const sev = (f.severity || 'low').toLowerCase();
|
|
253
|
+
html += '<div class="finding-item ' + sev + '">';
|
|
254
|
+
html += '<div class="finding-head">';
|
|
255
|
+
html += '<span class="finding-severity tag-' + (sev === 'critical' || sev === 'high' ? 'block' : 'warn') + '" style="background:' + sevColor(sev) + ';color:' + (sev === 'medium' || sev === 'low' ? '#000' : '#fff') + '">' + sev.toUpperCase() + '</span>';
|
|
256
|
+
if (f.rule_id) html += '<span class="finding-rule">' + f.rule_id + '</span>';
|
|
257
|
+
if (f.confidence) html += '<span class="finding-conf">conf=' + Math.round(f.confidence * 100) + '%</span>';
|
|
258
|
+
if (f.blocking) html += '<span class="badge badge-block">BLOCK</span>';
|
|
259
|
+
if (f.corroborated_by && f.corroborated_by.length) {
|
|
260
|
+
html += '<span class="finding-also" title="This issue was raised by more than one agent and reported once">also: ' + escapeHtml(f.corroborated_by.join(', ')) + '</span>';
|
|
261
|
+
}
|
|
262
|
+
html += '</div>';
|
|
263
|
+
if (f.problem) html += '<div class="finding-msg">' + escapeHtml(f.problem) + '</div>';
|
|
264
|
+
if (f.file) html += '<div class="finding-loc">' + escapeHtml(f.file) + (f.line ? ':' + f.line : '') + '</div>';
|
|
265
|
+
if (f.suggestion) html += '<div class="finding-suggestion">Suggestion: ' + escapeHtml(f.suggestion) + '</div>';
|
|
266
|
+
if (f.suggested_code) {
|
|
267
|
+
const isAI = String(f.agent || '').indexOf('ai_') === 0;
|
|
268
|
+
const label = isAI ? 'AI suggested code' : 'Suggested fix';
|
|
269
|
+
html += '<div class="finding-code-head">' + escapeHtml(label) + '</div>';
|
|
270
|
+
html += '<pre class="finding-code"><code>' + escapeHtml(f.suggested_code) + '</code></pre>';
|
|
271
|
+
}
|
|
272
|
+
html += '</div>';
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
html += '</div>'; // detail-panel
|
|
276
|
+
|
|
277
|
+
html += '</div>';
|
|
278
|
+
});
|
|
279
|
+
html += '</div>';
|
|
280
|
+
|
|
281
|
+
document.getElementById('reports').innerHTML = html;
|
|
282
|
+
|
|
283
|
+
// Attach click handlers for expand/collapse
|
|
284
|
+
document.querySelectorAll('.report-item').forEach(el => {
|
|
285
|
+
el.addEventListener('click', () => el.classList.toggle('expanded'));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Attach filter handlers
|
|
289
|
+
document.querySelectorAll('.chip').forEach(el => {
|
|
290
|
+
el.addEventListener('click', () => {
|
|
291
|
+
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
|
|
292
|
+
el.classList.add('active');
|
|
293
|
+
const filter = el.dataset.filter;
|
|
294
|
+
document.querySelectorAll('.report-item').forEach(item => {
|
|
295
|
+
const status = item.dataset.status;
|
|
296
|
+
const show = filter === 'all' || status === filter;
|
|
297
|
+
item.style.display = show ? '' : 'none';
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sevColor(sev) {
|
|
304
|
+
return {critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#22c55e'}[sev] || '#64748b';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// One-line token summary for a report card. Three states, never two:
|
|
308
|
+
// no field -> "—" (report written before token tracking)
|
|
309
|
+
// field, 0 calls -> "0 tokens (regex)"
|
|
310
|
+
// field, N calls -> "1,234 tokens" (+ "(est.)" when the provider returned
|
|
311
|
+
// no usage and PRISM had to estimate)
|
|
312
|
+
function tokenLabel(r) {
|
|
313
|
+
const tu = r.token_usage;
|
|
314
|
+
if (!tu) return '—';
|
|
315
|
+
if (!tu.calls) return '0 tokens (regex)';
|
|
316
|
+
const total = (tu.total_tokens || 0).toLocaleString();
|
|
317
|
+
return total + ' tokens' + (tu.estimated_calls ? ' (est.)' : '');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Expanded-view breakdown: input/output split plus per-provider totals.
|
|
321
|
+
function tokenDetail(r) {
|
|
322
|
+
const tu = r.token_usage;
|
|
323
|
+
if (!tu || !tu.calls) return '';
|
|
324
|
+
const parts = Object.entries(tu.by_provider || {}).map(([name, v]) => {
|
|
325
|
+
const sum = (v.input_tokens || 0) + (v.output_tokens || 0);
|
|
326
|
+
return escapeHtml(name) + ': ' + v.calls + ' call(s), ' + sum.toLocaleString() + ' tok';
|
|
327
|
+
});
|
|
328
|
+
const split = (tu.input_tokens || 0).toLocaleString() + ' in / '
|
|
329
|
+
+ (tu.output_tokens || 0).toLocaleString() + ' out';
|
|
330
|
+
const est = tu.estimated_calls ? ' (' + tu.estimated_calls + ' estimated)' : '';
|
|
331
|
+
return '<div class="finding-loc">Tokens: ' + split + est
|
|
332
|
+
+ (parts.length ? ' · ' + parts.join(' · ') : '') + '</div>';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function escapeHtml(s) {
|
|
336
|
+
if (!s) return '';
|
|
337
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
loadReports();
|
|
341
|
+
</script>
|
|
342
|
+
</body>
|
|
343
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# PRISM Pre-Commit Hook
|
|
3
|
+
# Runs PRISM review on staged files before allowing commit.
|
|
4
|
+
# Install: prism install-hook
|
|
5
|
+
# Or manually: copy this to .git/hooks/pre-commit and chmod +x
|
|
6
|
+
|
|
7
|
+
echo "PRISM: Running pre-commit review..."
|
|
8
|
+
|
|
9
|
+
# Run PRISM on staged changes. Human-readable report (not --json): the
|
|
10
|
+
# developer must be able to read the findings before the commit is blocked.
|
|
11
|
+
# The visual dashboard opens automatically after the review (see --no-dashboard
|
|
12
|
+
# or PRISM_NO_DASHBOARD to opt out on headless machines).
|
|
13
|
+
python -m prism.cli check-staged --prompt
|
|
14
|
+
EXIT_CODE=$?
|
|
15
|
+
|
|
16
|
+
# Show report interface after review completes
|
|
17
|
+
echo ""
|
|
18
|
+
echo "============================================"
|
|
19
|
+
echo " PRISM Review Complete"
|
|
20
|
+
echo "============================================"
|
|
21
|
+
echo ""
|
|
22
|
+
|
|
23
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
24
|
+
echo "Commit blocked. Fix the issues above and try again."
|
|
25
|
+
echo ""
|
|
26
|
+
echo "View detailed report:"
|
|
27
|
+
echo " prism reports # review history"
|
|
28
|
+
echo " prism dashboard # visual timeline"
|
|
29
|
+
echo " prism feedback # feedback loop stats"
|
|
30
|
+
exit 1
|
|
31
|
+
else
|
|
32
|
+
echo "All checks passed."
|
|
33
|
+
echo ""
|
|
34
|
+
echo "View report:"
|
|
35
|
+
echo " prism reports"
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
@@ -12,6 +12,7 @@ No server, no database, no cloud — just local JSON files.
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
15
16
|
from datetime import datetime, timezone
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import List, Optional
|
|
@@ -19,6 +20,50 @@ from typing import List, Optional
|
|
|
19
20
|
REPORTS_DIR = ".prism-reports"
|
|
20
21
|
REPORT_INDEX = os.path.join(REPORTS_DIR, "index.json")
|
|
21
22
|
|
|
23
|
+
# Well-known credential prefixes. A report is meant to be shared (the dashboard
|
|
24
|
+
# is a single self-contained HTML file), and an AI agent asked to fix a hardcoded
|
|
25
|
+
# secret can quote it back in `suggested_code`. Masking the value keeps the
|
|
26
|
+
# finding useful - rule, file, line - without copying the credential.
|
|
27
|
+
_SECRET_VALUE_PATTERNS = [
|
|
28
|
+
re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{10,}"),
|
|
29
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
|
30
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
|
|
31
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
32
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"),
|
|
33
|
+
re.compile(r"AIza[0-9A-Za-z_\-]{30,}"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
REDACTED = "***REDACTED***"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def redact_secrets(text):
|
|
40
|
+
"""Mask credential-shaped substrings in ``text``; non-strings pass through."""
|
|
41
|
+
if not isinstance(text, str) or not text:
|
|
42
|
+
return text
|
|
43
|
+
for pattern in _SECRET_VALUE_PATTERNS:
|
|
44
|
+
text = pattern.sub(REDACTED, text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def looks_like_secret(text) -> bool:
|
|
49
|
+
"""True when ``text`` contains something shaped like a live credential."""
|
|
50
|
+
return isinstance(text, str) and redact_secrets(text) != text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _redact_findings(findings: List[dict]) -> List[dict]:
|
|
54
|
+
"""Return a copy of ``findings`` with credential-shaped text masked."""
|
|
55
|
+
cleaned = []
|
|
56
|
+
for finding in findings:
|
|
57
|
+
if not isinstance(finding, dict):
|
|
58
|
+
cleaned.append(finding)
|
|
59
|
+
continue
|
|
60
|
+
safe = dict(finding)
|
|
61
|
+
for key in ("problem", "message", "suggestion", "suggested_code"):
|
|
62
|
+
if safe.get(key):
|
|
63
|
+
safe[key] = redact_secrets(safe[key])
|
|
64
|
+
cleaned.append(safe)
|
|
65
|
+
return cleaned
|
|
66
|
+
|
|
22
67
|
|
|
23
68
|
def _ensure_dir():
|
|
24
69
|
Path(REPORTS_DIR).mkdir(parents=True, exist_ok=True)
|
|
@@ -72,7 +117,7 @@ def save_report(
|
|
|
72
117
|
"risk_score": risk_score,
|
|
73
118
|
"commit_allowed": commit_allowed,
|
|
74
119
|
"token_usage": token_usage,
|
|
75
|
-
"findings": findings,
|
|
120
|
+
"findings": _redact_findings(findings),
|
|
76
121
|
}
|
|
77
122
|
|
|
78
123
|
# Save individual report
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prism-code-review
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Local pre-commit multi-agent code review (regex + AI providers)
|
|
5
5
|
Author: PRISM contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -197,21 +197,12 @@ There are only three SDKs to install. The five OpenAI-compatible providers
|
|
|
197
197
|
|
|
198
198
|
### Hand the tool to a teammate (no `cd`, no venv on their side)
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
**one** command from any folder — no virtualenv, no source checkout, no build
|
|
202
|
-
tools:
|
|
200
|
+
One command from any folder — no virtualenv, no source checkout, no build tools:
|
|
203
201
|
|
|
204
202
|
```bash
|
|
205
|
-
# On the machine that has the repo (you):
|
|
206
|
-
py -m build # -> dist/prism_code_review-2.0.0-py3-none-any.whl
|
|
207
|
-
# then: GitHub -> Releases -> Draft a new release -> tag v2.0.0
|
|
208
|
-
# -> attach the .whl -> Publish
|
|
209
|
-
|
|
210
203
|
# On the teammate's machine, once. The [all] extra installs every provider SDK
|
|
211
204
|
# plus python-dotenv; without an extra the regex agents run but AI agents skip:
|
|
212
|
-
|
|
213
|
-
# extras that are appended straight onto a direct wheel URL)
|
|
214
|
-
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
205
|
+
pip install "prism-code-review[all]"
|
|
215
206
|
|
|
216
207
|
# Set the API key once for every project (tool-wide):
|
|
217
208
|
prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
@@ -219,6 +210,18 @@ prism key set --global --provider gemini --key YOUR_GEMINI_API_KEY
|
|
|
219
210
|
# Then in any project folder: git init, prism init, prism check ...
|
|
220
211
|
```
|
|
221
212
|
|
|
213
|
+
No PyPI access (air-gapped machine, internal mirror)? Install straight from the
|
|
214
|
+
GitHub Release wheel instead. Note the `name[extras] @ url` form — pip does not
|
|
215
|
+
parse extras that are appended directly onto a wheel URL, so `...whl[all]`
|
|
216
|
+
returns a 404:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
pip install "prism-code-review[all] @ https://github.com/Bibekbwn/prism-code-review/releases/download/v2.0.0/prism_code_review-2.0.0-py3-none-any.whl"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Both routes give the same package. To cut a new release, see
|
|
223
|
+
[Releasing](#releasing).
|
|
224
|
+
|
|
222
225
|
Use `[all]` when you do not control which provider each developer will use. For
|
|
223
226
|
a team standardised on one provider, install only that extra — `[gemini]`,
|
|
224
227
|
`[claude]`, or `[openai]` (the last also covers openrouter, groq, together,
|
|
@@ -1380,12 +1383,22 @@ The suite covers:
|
|
|
1380
1383
|
## Releasing
|
|
1381
1384
|
|
|
1382
1385
|
```bash
|
|
1383
|
-
py -m build
|
|
1386
|
+
py -m build # -> dist/prism_code_review-<version>-*.whl and .tar.gz
|
|
1387
|
+
py -m twine check dist/* # fails the upload if the README does not render
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
Then publish to both places:
|
|
1391
|
+
|
|
1392
|
+
```bash
|
|
1393
|
+
# PyPI (the short install path)
|
|
1394
|
+
py -m twine upload dist/*
|
|
1395
|
+
|
|
1396
|
+
# GitHub (the wheel-only fallback for machines without PyPI access)
|
|
1397
|
+
#Releases -> Draft a new release -> tag v<version> -> attach the .whl -> Publish
|
|
1384
1398
|
```
|
|
1385
1399
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
wheel is never committed; the Release asset URL is what developers install from.
|
|
1400
|
+
`dist/` is git-ignored, so the wheel is never committed — the Release asset and
|
|
1401
|
+
the PyPI project are what developers install from.
|
|
1389
1402
|
|
|
1390
1403
|
The wheel declares no hard dependencies — providers are opt-in extras
|
|
1391
1404
|
(`[gemini]`, `[claude]`, `[openai]`, `[all]`, `[dev]`), so a base install stays
|
|
@@ -28,12 +28,14 @@ prism/codebase_cache.py
|
|
|
28
28
|
prism/commands_common.py
|
|
29
29
|
prism/commands_reports.py
|
|
30
30
|
prism/commands_setup.py
|
|
31
|
+
prism/dashboard.html
|
|
31
32
|
prism/feedback.py
|
|
32
33
|
prism/ignore.py
|
|
33
34
|
prism/langs.py
|
|
34
35
|
prism/logging_config.py
|
|
35
36
|
prism/lsp.py
|
|
36
37
|
prism/modes.py
|
|
38
|
+
prism/pre-commit-hook.sh
|
|
37
39
|
prism/reports.py
|
|
38
40
|
prism/token_usage.py
|
|
39
41
|
prism_code_review.egg-info/PKG-INFO
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prism-code-review"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.1"
|
|
8
8
|
description = "Local pre-commit multi-agent code review (regex + AI providers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -45,6 +45,12 @@ dev = [
|
|
|
45
45
|
[project.scripts]
|
|
46
46
|
prism = "prism.cli:main"
|
|
47
47
|
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
include-package-data = true
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
prism = ["dashboard.html", "pre-commit-hook.sh"]
|
|
53
|
+
|
|
48
54
|
[project.urls]
|
|
49
55
|
Homepage = "https://github.com/Bibekbwn/prism-code-review"
|
|
50
56
|
Issues = "https://github.com/Bibekbwn/prism-code-review/issues"
|
|
@@ -2508,3 +2508,88 @@ action="set", provider="bogus", key="sk-real-key-1234567890"))
|
|
|
2508
2508
|
cs._cmd_key(argparse.Namespace(
|
|
2509
2509
|
action="status", provider=None, key=None))
|
|
2510
2510
|
assert "No API keys configured" in capsys.readouterr().out
|
|
2511
|
+
|
|
2512
|
+
class TestSecretNeverEchoed:
|
|
2513
|
+
"""A suggested fix is rendered in the dashboard and the JSON report.
|
|
2514
|
+
|
|
2515
|
+
Returning the offending line verbatim copied the live credential into a
|
|
2516
|
+
file that is meant to be shared, so an unquoted assignment must be masked
|
|
2517
|
+
and an embedded credential must produce no fix at all.
|
|
2518
|
+
"""
|
|
2519
|
+
|
|
2520
|
+
def test_unquoted_assignment_is_masked(self):
|
|
2521
|
+
from prism.autofix import generate_fix
|
|
2522
|
+
line = "PASSWORD=hunter2"
|
|
2523
|
+
fix = generate_fix(_finding(rule_id="SECU-103"), line)
|
|
2524
|
+
assert "hunter2" not in (fix or "")
|
|
2525
|
+
assert "REDACTED" in (fix or "")
|
|
2526
|
+
|
|
2527
|
+
def test_shell_export_is_masked(self):
|
|
2528
|
+
from prism.autofix import generate_fix
|
|
2529
|
+
line = "export SECRET_KEY=abcdef1234567890"
|
|
2530
|
+
fix = generate_fix(_finding(rule_id="SECU-125"), line)
|
|
2531
|
+
assert "abcdef1234567890" not in (fix or "")
|
|
2532
|
+
|
|
2533
|
+
def test_embedded_credential_gets_no_fix(self):
|
|
2534
|
+
from prism.autofix import generate_fix
|
|
2535
|
+
line = ' login(user, "sk-live-abcdefghij123456")'
|
|
2536
|
+
assert generate_fix(_finding(), line) is None
|
|
2537
|
+
|
|
2538
|
+
def test_non_declaration_line_is_untouched(self):
|
|
2539
|
+
from prism.autofix import generate_fix
|
|
2540
|
+
line = ' if (token == "abc") {'
|
|
2541
|
+
assert generate_fix(_finding(), line) == line
|
|
2542
|
+
|
|
2543
|
+
def test_report_redacts_prefixed_credentials(self):
|
|
2544
|
+
from prism.reports import redact_secrets
|
|
2545
|
+
text = 'key = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"'
|
|
2546
|
+
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" not in redact_secrets(text)
|
|
2547
|
+
assert "REDACTED" in redact_secrets(text)
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
class TestBlockOnValidation:
|
|
2551
|
+
"""block_on drives the quality gate; a bare string silently disables it."""
|
|
2552
|
+
|
|
2553
|
+
def _set(self, tmp_path, monkeypatch, value):
|
|
2554
|
+
import prism.commands_reports as cr
|
|
2555
|
+
import prism.cli_config as cfg
|
|
2556
|
+
monkeypatch.chdir(tmp_path)
|
|
2557
|
+
(tmp_path / "prism.json").write_text('{"block_on": ["critical", "high"]}')
|
|
2558
|
+
monkeypatch.setattr(cfg, "get_config_path",
|
|
2559
|
+
lambda: str(tmp_path / "prism.json"))
|
|
2560
|
+
monkeypatch.setattr(cr, "get_config_path",
|
|
2561
|
+
lambda: str(tmp_path / "prism.json"))
|
|
2562
|
+
cr._cmd_config(argparse.Namespace(set=["block_on", value],
|
|
2563
|
+
get=None))
|
|
2564
|
+
return json.loads((tmp_path / "prism.json").read_text())
|
|
2565
|
+
|
|
2566
|
+
def test_bare_string_is_rejected(self, tmp_path, monkeypatch):
|
|
2567
|
+
with pytest.raises(SystemExit) as exc:
|
|
2568
|
+
self._set(tmp_path, monkeypatch, "not-a-list")
|
|
2569
|
+
assert exc.value.code == 1
|
|
2570
|
+
assert json.loads((tmp_path / "prism.json").read_text())["block_on"] == [
|
|
2571
|
+
"critical", "high"]
|
|
2572
|
+
|
|
2573
|
+
def test_unknown_severity_is_rejected(self, tmp_path, monkeypatch):
|
|
2574
|
+
with pytest.raises(SystemExit) as exc:
|
|
2575
|
+
self._set(tmp_path, monkeypatch, '["critical", "bogus"]')
|
|
2576
|
+
assert exc.value.code == 1
|
|
2577
|
+
|
|
2578
|
+
def test_valid_list_is_accepted(self, tmp_path, monkeypatch):
|
|
2579
|
+
config = self._set(tmp_path, monkeypatch, '["medium"]')
|
|
2580
|
+
assert config["block_on"] == ["medium"]
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
class TestDashboardPackaged:
|
|
2584
|
+
"""The dashboard template must ship inside the wheel, not just the repo."""
|
|
2585
|
+
|
|
2586
|
+
def test_template_is_next_to_the_module(self):
|
|
2587
|
+
import prism.commands_reports as cr
|
|
2588
|
+
template = Path(cr.__file__).parent / "dashboard.html"
|
|
2589
|
+
assert template.is_file(), "dashboard.html missing from the package"
|
|
2590
|
+
assert template.stat().st_size > 0
|
|
2591
|
+
|
|
2592
|
+
def test_hook_script_is_next_to_the_module(self):
|
|
2593
|
+
import prism.commands_setup as cs
|
|
2594
|
+
hook = Path(cs.__file__).parent / "pre-commit-hook.sh"
|
|
2595
|
+
assert hook.is_file(), "pre-commit-hook.sh missing from the package"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/top_level.txt
RENAMED
|
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
|