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.
Files changed (63) hide show
  1. {prism_code_review-2.0.0/prism_code_review.egg-info → prism_code_review-2.0.1}/PKG-INFO +29 -16
  2. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/README.md +28 -15
  3. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/__init__.py +1 -1
  4. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/autofix.py +37 -1
  5. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_commands.py +6 -0
  6. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_reports.py +32 -2
  7. prism_code_review-2.0.1/prism/dashboard.html +343 -0
  8. prism_code_review-2.0.1/prism/pre-commit-hook.sh +37 -0
  9. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/reports.py +46 -1
  10. {prism_code_review-2.0.0 → prism_code_review-2.0.1/prism_code_review.egg-info}/PKG-INFO +29 -16
  11. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/SOURCES.txt +2 -0
  12. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/pyproject.toml +7 -1
  13. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_regressions.py +85 -0
  14. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/LICENSE +0 -0
  15. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/__main__.py +0 -0
  16. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents.py +0 -0
  17. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_policy.py +0 -0
  18. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_quality.py +0 -0
  19. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_security.py +0 -0
  20. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/agents_test.py +0 -0
  21. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_architecture.py +0 -0
  22. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_common.py +0 -0
  23. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_findings.py +0 -0
  24. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_graph.py +0 -0
  25. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_performance.py +0 -0
  26. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_pipeline.py +0 -0
  27. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_policy.py +0 -0
  28. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_providers.py +0 -0
  29. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_quality.py +0 -0
  30. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_security.py +0 -0
  31. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ai_test.py +0 -0
  32. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli.py +0 -0
  33. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_config.py +0 -0
  34. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/cli_review.py +0 -0
  35. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/codebase_cache.py +0 -0
  36. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_common.py +0 -0
  37. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/commands_setup.py +0 -0
  38. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/feedback.py +0 -0
  39. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/ignore.py +0 -0
  40. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/langs.py +0 -0
  41. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/logging_config.py +0 -0
  42. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/lsp.py +0 -0
  43. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/modes.py +0 -0
  44. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism/token_usage.py +0 -0
  45. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/dependency_links.txt +0 -0
  46. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/entry_points.txt +0 -0
  47. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/requires.txt +0 -0
  48. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/prism_code_review.egg-info/top_level.txt +0 -0
  49. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/setup.cfg +0 -0
  50. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_autofix_languages.py +0 -0
  51. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_call_graph.py +0 -0
  52. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_cli_handlers.py +0 -0
  53. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_coverage_paths.py +0 -0
  54. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_global_key.py +0 -0
  55. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_golden_corpus.py +0 -0
  56. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_integration.py +0 -0
  57. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_languages.py +0 -0
  58. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_lsp.py +0 -0
  59. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_product.py +0 -0
  60. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_product_e2e.py +0 -0
  61. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_providers.py +0 -0
  62. {prism_code_review-2.0.0 → prism_code_review-2.0.1}/tests/test_security.py +0 -0
  63. {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.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
- Build the wheel once, attach it to a GitHub Release, and the teammate runs
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
- # (the "prism-code-review[all] @ <url>" form is required: pip does not parse
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 # -> dist/prism_code_review-<version>-py3-none-any.whl
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
- Then attach the wheel to a GitHub Release (Releases → Draft a new release → tag
1387
- `v<version>` → attach the `.whl` → Publish). `dist/` is git-ignored, so the
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
- Build the wheel once, attach it to a GitHub Release, and the teammate runs
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
- # (the "prism-code-review[all] @ <url>" form is required: pip does not parse
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 # -> dist/prism_code_review-<version>-py3-none-any.whl
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
- Then attach the wheel to a GitHub Release (Releases → Draft a new release → tag
1347
- `v<version>` → attach the `.whl` → Publish). `dist/` is git-ignored, so the
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
@@ -8,7 +8,7 @@ before code is pushed to GitHub. Each agent checks a specific concern.
8
8
  import os
9
9
  from pathlib import Path
10
10
 
11
- __version__ = "2.0.0"
11
+ __version__ = "2.0.1"
12
12
 
13
13
  _ENV_LOADED = False
14
14
 
@@ -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
- build_dashboard(open_browser=True)
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 + ' &middot; ' + (r.branch || 'main') + ' &middot; ' + (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 ? ' &middot; ' + parts.join(' &middot; ') : '') + '</div>';
333
+ }
334
+
335
+ function escapeHtml(s) {
336
+ if (!s) return '';
337
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.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
- Build the wheel once, attach it to a GitHub Release, and the teammate runs
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
- # (the "prism-code-review[all] @ <url>" form is required: pip does not parse
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 # -> dist/prism_code_review-<version>-py3-none-any.whl
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
- Then attach the wheel to a GitHub Release (Releases → Draft a new release → tag
1387
- `v<version>` → attach the `.whl` → Publish). `dist/` is git-ignored, so the
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.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"