open-code-review-toolkit 0.6.0__tar.gz → 0.6.2__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 (91) hide show
  1. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/PKG-INFO +5 -1
  2. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/README.md +3 -0
  3. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/pyproject.toml +1 -0
  4. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/_version.py +2 -2
  5. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/cli.py +6 -1
  6. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/artifacts.py +24 -3
  7. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collect.py +35 -3
  8. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/orchestration.py +37 -22
  9. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/contracts.py +1 -0
  10. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/templates.py +4 -1
  11. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/mcp.py +86 -31
  12. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/model.py +5 -2
  13. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/schema.py +1 -1
  14. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/project.py +69 -45
  15. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/repository.py +47 -1
  16. open_code_review_toolkit-0.6.2/src/ocr_toolkit/evidence/review_context.py +348 -0
  17. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/contracts.py +3 -2
  18. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/core.py +62 -14
  19. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/readback.py +25 -5
  20. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/mcp_config.py +4 -0
  21. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/ocr_result.py +7 -1
  22. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/approval.py +50 -1
  23. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/formatting.py +2 -2
  24. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/workflow.py +1 -0
  25. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/preflight.py +1 -1
  26. open_code_review_toolkit-0.6.2/src/ocr_toolkit/providers/gitlab.py +274 -0
  27. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/review_runner.py +134 -10
  28. open_code_review_toolkit-0.6.0/src/ocr_toolkit/providers/gitlab.py +0 -38
  29. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/.gitignore +0 -0
  30. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/LICENSE +0 -0
  31. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/__init__.py +0 -0
  32. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/__init__.py +0 -0
  33. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/filesystem.py +0 -0
  34. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/git.py +0 -0
  35. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/language.py +0 -0
  36. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/markdown.py +0 -0
  37. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/common/redaction.py +0 -0
  38. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/config_writer.py +0 -0
  39. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/configure.py +0 -0
  40. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/__init__.py +0 -0
  41. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/__main__.py +0 -0
  42. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/categorize.py +0 -0
  43. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/__init__.py +0 -0
  44. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/graphs.py +0 -0
  45. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/projections.py +0 -0
  46. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/registry.py +0 -0
  47. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/collectors/sources.py +0 -0
  48. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/coverage.py +0 -0
  49. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/__init__.py +0 -0
  50. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py +0 -0
  51. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/ansible/requirements.py +0 -0
  52. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/ansible/topology.py +0 -0
  53. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/contracts.py +0 -0
  54. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/go.py +0 -0
  55. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/javascript.py +0 -0
  56. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/php.py +0 -0
  57. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/ecosystems/python.py +0 -0
  58. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/__init__.py +0 -0
  59. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/detection.py +0 -0
  60. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/providers/__init__.py +0 -0
  61. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/providers/frontend.py +0 -0
  62. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/providers/go.py +0 -0
  63. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/providers/php.py +0 -0
  64. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/providers/python.py +0 -0
  65. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/registry.py +0 -0
  66. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/frameworks/schema.py +0 -0
  67. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
  68. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/invocation.py +0 -0
  69. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/__init__.py +0 -0
  70. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/contracts.py +0 -0
  71. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/decisions.py +0 -0
  72. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/guidance.py +0 -0
  73. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/registry.py +0 -0
  74. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/policy/scopes.py +0 -0
  75. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/__init__.py +0 -0
  76. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/atomic.py +0 -0
  77. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/evidence/store/values.py +0 -0
  78. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/__init__.py +0 -0
  79. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/__main__.py +0 -0
  80. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/comments.py +0 -0
  81. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/gitlab.py +0 -0
  82. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/gitlab_approval.py +0 -0
  83. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/markers.py +0 -0
  84. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/payloads.py +0 -0
  85. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/result.py +0 -0
  86. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/settings.py +0 -0
  87. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/snapshot.py +0 -0
  88. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/posting/suggestions.py +0 -0
  89. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/providers/__init__.py +0 -0
  90. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/py.typed +0 -0
  91. {open_code_review_toolkit-0.6.0 → open_code_review_toolkit-0.6.2}/src/ocr_toolkit/result_contract.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Unofficial GitLab CI integration layer for Open Code Review
5
5
  Project-URL: Homepage, https://github.com/xeonvs/open-code-review-toolkit
6
6
  Project-URL: Repository, https://github.com/xeonvs/open-code-review-toolkit
7
7
  Project-URL: Issues, https://github.com/xeonvs/open-code-review-toolkit/issues
8
8
  Project-URL: Changelog, https://github.com/xeonvs/open-code-review-toolkit/blob/main/CHANGELOG.md
9
+ Project-URL: Code of Conduct, https://github.com/xeonvs/open-code-review-toolkit/blob/main/CODE_OF_CONDUCT.md
9
10
  Author: Open Code Review Toolkit contributors
10
11
  License:
11
12
  Apache License
@@ -227,6 +228,8 @@ Description-Content-Type: text/markdown
227
228
  # Open Code Review Toolkit
228
229
 
229
230
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13906/badge)](https://www.bestpractices.dev/projects/13906)
231
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/xeonvs/open-code-review-toolkit/badge)](https://securityscorecards.dev/viewer/?uri=github.com/xeonvs/open-code-review-toolkit)
232
+ [![CodeQL](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml)
230
233
 
231
234
  Open Code Review Toolkit is an unofficial GitLab CI integration layer for [Alibaba Open Code Review](https://github.com/alibaba/open-code-review). It provides bounded repository evidence, a compact review bootstrap, a built-in read-only MCP server, environment-driven OCR configuration, preflight validation, and safe GitLab merge-request posting. It does **not** bundle or download the `ocr` binary.
232
235
 
@@ -297,6 +300,7 @@ Repository content, OCR output, and provider responses are untrusted inputs. The
297
300
  ## Development and release
298
301
 
299
302
  - [Contributing](CONTRIBUTING.md)
303
+ - [Code of Conduct](CODE_OF_CONDUCT.md)
300
304
  - [Development guide](docs/development.md)
301
305
  - [Security policy](SECURITY.md)
302
306
  - [Release process](docs/release.md)
@@ -1,6 +1,8 @@
1
1
  # Open Code Review Toolkit
2
2
 
3
3
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13906/badge)](https://www.bestpractices.dev/projects/13906)
4
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/xeonvs/open-code-review-toolkit/badge)](https://securityscorecards.dev/viewer/?uri=github.com/xeonvs/open-code-review-toolkit)
5
+ [![CodeQL](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/xeonvs/open-code-review-toolkit/actions/workflows/codeql.yml)
4
6
 
5
7
  Open Code Review Toolkit is an unofficial GitLab CI integration layer for [Alibaba Open Code Review](https://github.com/alibaba/open-code-review). It provides bounded repository evidence, a compact review bootstrap, a built-in read-only MCP server, environment-driven OCR configuration, preflight validation, and safe GitLab merge-request posting. It does **not** bundle or download the `ocr` binary.
6
8
 
@@ -71,6 +73,7 @@ Repository content, OCR output, and provider responses are untrusted inputs. The
71
73
  ## Development and release
72
74
 
73
75
  - [Contributing](CONTRIBUTING.md)
76
+ - [Code of Conduct](CODE_OF_CONDUCT.md)
74
77
  - [Development guide](docs/development.md)
75
78
  - [Security policy](SECURITY.md)
76
79
  - [Release process](docs/release.md)
@@ -30,6 +30,7 @@ Homepage = "https://github.com/xeonvs/open-code-review-toolkit"
30
30
  Repository = "https://github.com/xeonvs/open-code-review-toolkit"
31
31
  Issues = "https://github.com/xeonvs/open-code-review-toolkit/issues"
32
32
  Changelog = "https://github.com/xeonvs/open-code-review-toolkit/blob/main/CHANGELOG.md"
33
+ "Code of Conduct" = "https://github.com/xeonvs/open-code-review-toolkit/blob/main/CODE_OF_CONDUCT.md"
33
34
 
34
35
  [project.scripts]
35
36
  ocr-ci = "ocr_toolkit.cli:main"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.6.2'
22
+ __version_tuple__ = version_tuple = (0, 6, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,7 +7,7 @@ import sys
7
7
  from collections.abc import Sequence
8
8
  from pathlib import Path
9
9
 
10
- from ocr_toolkit import configure, mcp_config, preflight, review_runner
10
+ from ocr_toolkit import __version__, configure, mcp_config, preflight, review_runner
11
11
  from ocr_toolkit.posting.workflow import main as posting_main
12
12
 
13
13
 
@@ -18,6 +18,11 @@ def build_parser() -> argparse.ArgumentParser:
18
18
  prog="ocr-ci",
19
19
  description="Safe CI integration helpers for Open Code Review.",
20
20
  )
21
+ parser.add_argument(
22
+ "--version",
23
+ action="version",
24
+ version=f"%(prog)s {__version__}",
25
+ )
21
26
  subparsers = parser.add_subparsers(dest="command", required=True)
22
27
  subparsers.add_parser("preflight", help="Validate OCR, GitLab, and LLM access.")
23
28
  subparsers.add_parser("configure", help="Write the OCR runtime configuration.")
@@ -15,6 +15,7 @@ class EvidenceArtifacts:
15
15
  directory: Path
16
16
  store: Path
17
17
  bootstrap: Path
18
+ policy_rules: Path
18
19
 
19
20
 
20
21
  def repository_artifacts(root: Path | None = None) -> EvidenceArtifacts:
@@ -26,6 +27,7 @@ def repository_artifacts(root: Path | None = None) -> EvidenceArtifacts:
26
27
  directory=directory,
27
28
  store=directory / "evidence.json",
28
29
  bootstrap=directory / "bootstrap.md",
30
+ policy_rules=directory / "policy-rules.json",
29
31
  )
30
32
 
31
33
 
@@ -42,8 +44,8 @@ def prepare_artifact_directory(artifacts: EvidenceArtifacts) -> None:
42
44
  directory.chmod(0o700)
43
45
 
44
46
 
45
- def write_private_text(path: Path, content: str) -> None:
46
- """Write one internal text artifact without following the final symlink."""
47
+ def write_private_bytes(path: Path, content: bytes) -> None:
48
+ """Write one private byte artifact without following the final symlink."""
47
49
 
48
50
  if path.parent.is_symlink() or not path.parent.is_dir():
49
51
  raise OSError(f"private artifact parent is not a safe directory: {path.parent}")
@@ -63,7 +65,7 @@ def write_private_text(path: Path, content: str) -> None:
63
65
  raise OSError(f"private artifact is an existing hard link: {path}")
64
66
  os.fchmod(descriptor, 0o600)
65
67
  os.ftruncate(descriptor, 0)
66
- stream = os.fdopen(descriptor, "w", encoding="utf-8")
68
+ stream = os.fdopen(descriptor, "wb")
67
69
  descriptor = -1
68
70
  with stream:
69
71
  stream.write(content)
@@ -72,3 +74,22 @@ def write_private_text(path: Path, content: str) -> None:
72
74
  if descriptor >= 0:
73
75
  os.close(descriptor)
74
76
  raise
77
+
78
+
79
+ def write_private_text(path: Path, content: str) -> None:
80
+ """Write one internal UTF-8 artifact through the private byte boundary."""
81
+
82
+ write_private_bytes(path, content.encode("utf-8"))
83
+
84
+
85
+ def remove_private_artifact(path: Path) -> None:
86
+ """Remove one toolkit-owned artifact name without following its target."""
87
+
88
+ if path.parent.is_symlink() or not path.parent.is_dir():
89
+ raise OSError(f"private artifact parent is not a safe directory: {path.parent}")
90
+ try:
91
+ path.unlink()
92
+ except FileNotFoundError:
93
+ return
94
+ except IsADirectoryError as exc:
95
+ raise OSError(f"private artifact path is not a file: {path}") from exc
@@ -16,6 +16,7 @@ from ocr_toolkit.evidence.repository import (
16
16
  file_deltas,
17
17
  )
18
18
  from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError
19
+ from ocr_toolkit.evidence.store.contracts import POLICY_KINDS
19
20
 
20
21
 
21
22
  def _commit_refs(
@@ -44,13 +45,18 @@ def _component_for_path(path: str) -> str:
44
45
 
45
46
 
46
47
  def collect_repository_evidence(
47
- root: Path | None = None, *, base_ref: str | None = None, head_ref: str | None = None
48
+ root: Path | None = None,
49
+ *,
50
+ base_ref: str | None = None,
51
+ head_ref: str | None = None,
52
+ policy_ref: str | None = None,
48
53
  ) -> EvidenceStore:
49
54
  """Build one evidence store from immutable refs and existing bounded collectors."""
50
55
 
51
56
  reader = GitRepositoryReader(root or Path.cwd())
52
57
  base_sha, head_sha = _commit_refs(reader, base_ref, head_ref)
53
58
  changed = reader.changed_paths(base_sha, head_sha)
59
+ policy_sha = reader.resolve_commit(policy_ref) if policy_ref is not None else base_sha
54
60
  base = build_file_snapshot(reader, base_sha, RefRole.BASE, paths=changed)
55
61
  head = build_file_snapshot(reader, head_sha, RefRole.HEAD, paths=changed)
56
62
  base_paths = {record.source_path for record in base.records}
@@ -64,6 +70,19 @@ def collect_repository_evidence(
64
70
  changed_paths=changed,
65
71
  coverage_sink=base_coverage,
66
72
  )
73
+ policy_facts, policy_diagnostics = collect_ref_facts(
74
+ reader,
75
+ policy_sha,
76
+ RefRole.POLICY,
77
+ changed_paths=changed,
78
+ )
79
+ policy_facts = [record for record in policy_facts if record.kind in POLICY_KINDS]
80
+ policy = EvidenceSnapshot(
81
+ RefRole.POLICY,
82
+ policy_sha,
83
+ tuple(policy_facts),
84
+ diagnostics=tuple(policy_diagnostics),
85
+ )
67
86
  head_facts, head_fact_diagnostics = collect_ref_facts(
68
87
  reader,
69
88
  head_sha,
@@ -87,10 +106,12 @@ def collect_repository_evidence(
87
106
  )
88
107
  all_coverage = tuple((*base.coverage, *head.coverage))
89
108
  snapshot_deltas = file_deltas(base, head)
90
- store = EvidenceStore(base=base, head=head)
109
+ store = EvidenceStore(base=base, head=head, policy=policy)
91
110
  typed_facts = [*base_facts, *head_facts]
92
111
  rejected_snapshot_records = [
93
- record for record in (*base.records, *head.records) if not store.add(record)
112
+ record
113
+ for record in (*base.records, *head.records, *policy.records)
114
+ if not store.add(record)
94
115
  ]
95
116
  if rejected_snapshot_records:
96
117
  # Snapshots and their record-id indexes are one atomic contract. Persisting a
@@ -98,6 +119,16 @@ def collect_repository_evidence(
98
119
  raise EvidenceStoreError(
99
120
  "repository snapshot records exceed the configured evidence store limits"
100
121
  )
122
+ store.policy = EvidenceSnapshot(
123
+ RefRole.POLICY,
124
+ policy_sha,
125
+ tuple(
126
+ record
127
+ for record in store.records
128
+ if record.ref is RefRole.POLICY and record.commit_sha == policy_sha
129
+ ),
130
+ diagnostics=policy.diagnostics,
131
+ )
101
132
  for coverage in all_coverage:
102
133
  if not store.add_coverage(coverage):
103
134
  raise EvidenceStoreError(
@@ -174,6 +205,7 @@ def collect_repository_evidence(
174
205
  *head.diagnostics,
175
206
  *base_fact_diagnostics,
176
207
  *head_fact_diagnostics,
208
+ *policy_diagnostics,
177
209
  ):
178
210
  store.add_diagnostic(diagnostic)
179
211
  return store
@@ -76,7 +76,11 @@ def collect_ref_facts(
76
76
 
77
77
  records = []
78
78
  diagnostics = []
79
- trust = TrustClass.TARGET_REPOSITORY if ref == RefRole.BASE else TrustClass.SOURCE_REPOSITORY
79
+ trust = (
80
+ TrustClass.TARGET_REPOSITORY
81
+ if ref in {RefRole.BASE, RefRole.POLICY}
82
+ else TrustClass.SOURCE_REPOSITORY
83
+ )
80
84
  changed_exact = tuple(sorted(set(changed_paths)))
81
85
  changed = {path.casefold() for path in changed_exact}
82
86
  entries = reader.list_objects(commit_sha)
@@ -118,7 +122,7 @@ def collect_ref_facts(
118
122
  changed_exact,
119
123
  )
120
124
  )
121
- if ref is RefRole.BASE
125
+ if ref is RefRole.POLICY
122
126
  else set()
123
127
  )
124
128
  applicable_guidance = tuple(
@@ -158,7 +162,7 @@ def collect_ref_facts(
158
162
  (
159
163
  entry
160
164
  for entry in entries
161
- if ref is RefRole.BASE and entry.path == ACCEPTED_DECISIONS_PATH
165
+ if ref is RefRole.POLICY and entry.path == ACCEPTED_DECISIONS_PATH
162
166
  ),
163
167
  None,
164
168
  )
@@ -188,7 +192,8 @@ def collect_ref_facts(
188
192
  candidates = tuple(
189
193
  entry
190
194
  for entry in entries
191
- if (
195
+ if ref is not RefRole.POLICY
196
+ and (
192
197
  is_supported_manifest(entry.path)
193
198
  or PurePosixPath(entry.path).name.casefold().startswith(".gitlab-ci")
194
199
  or is_context_yaml(entry.path, changed)
@@ -380,7 +385,7 @@ def collect_ref_facts(
380
385
  ]
381
386
  elif path == ACCEPTED_DECISIONS_PATH:
382
387
  facts = []
383
- if ref == RefRole.BASE:
388
+ if ref == RefRole.POLICY:
384
389
  parsed_decisions = parse_accepted_decisions(text, changed_paths=changed_exact)
385
390
  diagnostics.extend(
386
391
  f"{ref.value}:{path}: {notice}" for notice in parsed_decisions.diagnostics
@@ -396,7 +401,7 @@ def collect_ref_facts(
396
401
  ]
397
402
  elif guidance_source:
398
403
  facts = []
399
- if ref == RefRole.BASE and path in policy_paths:
404
+ if ref == RefRole.POLICY and path in policy_paths:
400
405
  document = guidance_document(path, text, changed_exact)
401
406
  facts = [
402
407
  ManifestFact(
@@ -490,25 +495,35 @@ def collect_ref_facts(
490
495
  trust=trust,
491
496
  )
492
497
  )
493
- plugin_context = FrameworkPluginContext(
494
- records=tuple(records),
495
- entries=entries,
496
- source_statuses=tuple(sorted(source_statuses.values(), key=lambda item: item.path)),
497
- ref=ref,
498
- commit_sha=commit_sha,
499
- )
500
- plugin_facts, plugin_observations, plugin_notices = collect_framework_plugins(plugin_context)
501
- template_facts, template_observations, template_notices = collect_template_files(plugin_context)
502
- records.extend(
503
- plugin_records(
504
- (*plugin_facts, *template_facts),
498
+ plugin_observations = ()
499
+ template_observations = ()
500
+ if ref is not RefRole.POLICY:
501
+ plugin_context = FrameworkPluginContext(
502
+ records=tuple(records),
503
+ entries=entries,
504
+ source_statuses=tuple(sorted(source_statuses.values(), key=lambda item: item.path)),
505
+ changed_paths=changed_exact,
505
506
  ref=ref,
506
507
  commit_sha=commit_sha,
507
- trust=trust,
508
508
  )
509
- )
510
- diagnostics.extend(f"{ref.value}:{notice}" for notice in (*plugin_notices, *template_notices))
511
- if coverage_sink is not None:
509
+ plugin_facts, plugin_observations, plugin_notices = collect_framework_plugins(
510
+ plugin_context
511
+ )
512
+ template_facts, template_observations, template_notices = collect_template_files(
513
+ plugin_context
514
+ )
515
+ records.extend(
516
+ plugin_records(
517
+ (*plugin_facts, *template_facts),
518
+ ref=ref,
519
+ commit_sha=commit_sha,
520
+ trust=trust,
521
+ )
522
+ )
523
+ diagnostics.extend(
524
+ f"{ref.value}:{notice}" for notice in (*plugin_notices, *template_notices)
525
+ )
526
+ if coverage_sink is not None and ref is not RefRole.POLICY:
512
527
  for (domain, scope), observations in sorted(coverage_observations.items()):
513
528
  coverage_sink.append(
514
529
  compose_coverage(
@@ -59,6 +59,7 @@ class FrameworkPluginContext:
59
59
  records: tuple[EvidenceRecord, ...]
60
60
  entries: tuple[RepositoryObject, ...]
61
61
  source_statuses: tuple[PluginSourceStatus, ...]
62
+ changed_paths: tuple[str, ...]
62
63
  ref: RefRole
63
64
  commit_sha: str
64
65
 
@@ -93,7 +93,10 @@ def collect_template_files(
93
93
  }
94
94
  limited_components: set[tuple[str, str]] = set()
95
95
  truncated = False
96
- for entry in sorted(context.entries, key=lambda item: item.path):
96
+ changed_paths = frozenset(context.changed_paths)
97
+ for entry in sorted(
98
+ context.entries, key=lambda item: (item.path not in changed_paths, item.path)
99
+ ):
97
100
  description = _template_description(entry.path, context)
98
101
  if description is None:
99
102
  continue
@@ -93,6 +93,16 @@ def evidence_summary(store: EvidenceStore) -> dict[str, object]:
93
93
  for record in store.records
94
94
  if record.kind in {"repository.accepted_decision", "repository.guidance"}
95
95
  )
96
+ mr_context_records = tuple(
97
+ record for record in store.records if record.kind == "review.merge_request_context"
98
+ )
99
+ merge_request_context = {
100
+ "contract": ("review.merge-request-context/v1" if mr_context_records else "absent"),
101
+ "records": len(mr_context_records),
102
+ "trust": "invocation" if mr_context_records else None,
103
+ "content_role": "untrusted_data" if mr_context_records else None,
104
+ "authoritative_for_actions": False,
105
+ }
96
106
  policy = {
97
107
  "accepted_decisions": sum(
98
108
  record.kind == "repository.accepted_decision" for record in policy_records
@@ -102,7 +112,7 @@ def evidence_summary(store: EvidenceStore) -> dict[str, object]:
102
112
  ),
103
113
  "structured_target_records": sum(
104
114
  not is_legacy_policy_value(record.value)
105
- and record.ref.value == "base"
115
+ and record.ref.value in {"base", "policy"}
106
116
  and record.trust.value == "target_repository"
107
117
  for record in policy_records
108
118
  ),
@@ -110,14 +120,16 @@ def evidence_summary(store: EvidenceStore) -> dict[str, object]:
110
120
  is_legacy_policy_value(record.value) for record in policy_records
111
121
  ),
112
122
  "target_only": all(
113
- record.ref.value == "base" and record.trust.value == "target_repository"
123
+ record.ref.value in {"base", "policy"} and record.trust.value == "target_repository"
114
124
  for record in policy_records
115
125
  ),
116
126
  "authoritative_for_actions": False,
117
127
  }
118
128
  return {
119
- "schema_version": 3,
129
+ "schema_version": store.schema_version,
120
130
  "policy": policy,
131
+ "merge_request_context": merge_request_context,
132
+ "policy_ref": store.policy.commit_sha if store.policy else None,
121
133
  "coverage_contract": ("repository.evidence-coverage/v1" if store.coverage else "absent"),
122
134
  "base": store.base.commit_sha if store.base else None,
123
135
  "head": store.head.commit_sha if store.head else None,
@@ -136,9 +148,9 @@ def _optional_filter(arguments: dict[str, object], name: str) -> str | None:
136
148
  """Read one bounded optional exact-match filter."""
137
149
 
138
150
  value = arguments.get(name)
139
- if value is None:
151
+ if value is None or value == "":
140
152
  return None
141
- if not isinstance(value, str) or not value or len(value) > 256:
153
+ if not isinstance(value, str) or len(value) > 256:
142
154
  raise EvidenceMCPError(f"{name} must be a non-empty string of at most 256 characters")
143
155
  return value
144
156
 
@@ -154,9 +166,9 @@ def _encode_cursor(offset: int, query: _Query) -> str:
154
166
  def _decode_cursor(value: object, query: _Query) -> int:
155
167
  """Validate and decode a cursor bound to the current filters."""
156
168
 
157
- if value is None:
169
+ if value is None or value == "":
158
170
  return 0
159
- if not isinstance(value, str) or not value or len(value) > 256:
171
+ if not isinstance(value, str) or len(value) > 256:
160
172
  raise EvidenceMCPError("cursor must be a bounded opaque string")
161
173
  try:
162
174
  padded = value + "=" * (-len(value) % 4)
@@ -185,8 +197,8 @@ def _list_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[st
185
197
  raise EvidenceMCPError("delta_kind requires kind=repository.evidence_delta")
186
198
  if query.kind == "repository.evidence_delta" and query.ref is not None:
187
199
  raise EvidenceMCPError("evidence deltas span base and head and do not accept ref")
188
- if query.ref not in {None, "base", "head", "shared"}:
189
- raise EvidenceMCPError("ref must be base, head, or shared")
200
+ if query.ref not in {None, "base", "head", "policy", "shared"}:
201
+ raise EvidenceMCPError("ref must be base, head, policy, or shared")
190
202
  page_size = arguments.get("page_size", DEFAULT_PAGE_SIZE)
191
203
  if isinstance(page_size, bool) or not isinstance(page_size, int):
192
204
  raise EvidenceMCPError("page_size must be an integer")
@@ -254,29 +266,33 @@ def call_tool(store: EvidenceStore, arguments: object) -> dict[str, object]:
254
266
  if not isinstance(arguments, dict):
255
267
  raise EvidenceMCPError("tool arguments must be an object")
256
268
  typed = cast(dict[str, object], arguments)
269
+ # The public schema is one union-shaped object. OCR/provider adapters may
270
+ # materialize every declared property even when an action does not consume
271
+ # it, so reject unknown names globally and let each action read only its own
272
+ # fields.
273
+ declared = {
274
+ "action",
275
+ "kind",
276
+ "delta_kind",
277
+ "component",
278
+ "ref",
279
+ "page_size",
280
+ "cursor",
281
+ "id",
282
+ }
283
+ unknown = set(typed) - declared
284
+ if unknown:
285
+ raise EvidenceMCPError(f"unsupported tool argument: {sorted(unknown)[0]}")
286
+
257
287
  action = typed.get("action")
258
288
  if action == "summary":
259
- allowed = {"action"}
260
289
  payload = evidence_summary(store)
261
290
  elif action == "list":
262
- allowed = {
263
- "action",
264
- "kind",
265
- "delta_kind",
266
- "component",
267
- "ref",
268
- "page_size",
269
- "cursor",
270
- }
271
291
  payload = _list_records(store, typed)
272
292
  elif action == "get":
273
- allowed = {"action", "id"}
274
293
  payload = _get_record(store, typed)
275
294
  else:
276
295
  raise EvidenceMCPError("action must be summary, list, or get")
277
- unknown = set(typed) - allowed
278
- if unknown:
279
- raise EvidenceMCPError(f"unsupported tool argument: {sorted(unknown)[0]}")
280
296
  return _text_result(payload)
281
297
 
282
298
 
@@ -299,14 +315,53 @@ def _tool_definition() -> dict[str, object]:
299
315
  "additionalProperties": False,
300
316
  "required": ["action"],
301
317
  "properties": {
302
- "action": {"type": "string", "enum": ["summary", "list", "get"]},
303
- "kind": {"type": "string", "maxLength": 256},
304
- "delta_kind": {"type": "string", "maxLength": 256},
305
- "component": {"type": "string", "maxLength": 256},
306
- "ref": {"type": "string", "enum": ["base", "head", "shared"]},
307
- "page_size": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE},
308
- "cursor": {"type": "string", "maxLength": 256},
309
- "id": {"type": "string", "pattern": "^(ev1|cov1|del1)_[0-9a-f]{64}$"},
318
+ "action": {
319
+ "type": "string",
320
+ "enum": ["summary", "list", "get"],
321
+ "description": (
322
+ "Use summary for counts, list to filter records, and get only after "
323
+ "list returns a stable record id."
324
+ ),
325
+ },
326
+ "kind": {
327
+ "type": "string",
328
+ "maxLength": 256,
329
+ "description": "Optional exact record-kind filter for action=list only.",
330
+ },
331
+ "delta_kind": {
332
+ "type": "string",
333
+ "maxLength": 256,
334
+ "description": (
335
+ "Optional original fact-kind filter for action=list with "
336
+ "kind=repository.evidence_delta only."
337
+ ),
338
+ },
339
+ "component": {
340
+ "type": "string",
341
+ "maxLength": 256,
342
+ "description": "Optional exact component filter for action=list only.",
343
+ },
344
+ "ref": {
345
+ "type": "string",
346
+ "enum": ["base", "head", "policy", "shared"],
347
+ "description": "Optional immutable-ref filter for action=list only.",
348
+ },
349
+ "page_size": {
350
+ "type": "integer",
351
+ "minimum": 1,
352
+ "maximum": MAX_PAGE_SIZE,
353
+ "description": "Bounded page size for action=list only.",
354
+ },
355
+ "cursor": {
356
+ "type": "string",
357
+ "maxLength": 256,
358
+ "description": "Opaque next_cursor from a prior action=list call.",
359
+ },
360
+ "id": {
361
+ "type": "string",
362
+ "pattern": "^(ev1|cov1|del1)_[0-9a-f]{64}$",
363
+ "description": "Stable record id returned by action=list; action=get only.",
364
+ },
310
365
  },
311
366
  },
312
367
  "annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
@@ -42,6 +42,7 @@ class RefRole(str, Enum):
42
42
 
43
43
  BASE = "base"
44
44
  HEAD = "head"
45
+ POLICY = "policy"
45
46
  SHARED = "shared"
46
47
 
47
48
 
@@ -283,7 +284,7 @@ class CoverageRecord:
283
284
  or any(ord(character) < 32 for character in value)
284
285
  ):
285
286
  raise ValueError(f"coverage {name} must contain between 1 and 256 safe characters")
286
- if self.ref is RefRole.SHARED:
287
+ if self.ref not in {RefRole.BASE, RefRole.HEAD}:
287
288
  raise ValueError("coverage ref must be base or head")
288
289
  if len(self.commit_sha) != 40 or not all(
289
290
  character in "0123456789abcdef" for character in self.commit_sha
@@ -407,7 +408,9 @@ class EvidenceSnapshot:
407
408
  """Ensure snapshot records match the declared ref and are ordered."""
408
409
 
409
410
  if self.ref is RefRole.SHARED:
410
- raise ValueError("snapshot ref must be base or head")
411
+ raise ValueError("snapshot ref must be base, head, or policy")
412
+ if self.ref is RefRole.POLICY and self.coverage:
413
+ raise ValueError("policy snapshot cannot contain coverage")
411
414
  if len(self.commit_sha) != 40 or not all(c in "0123456789abcdef" for c in self.commit_sha):
412
415
  raise ValueError("snapshot commit_sha must be a lowercase 40-character SHA-1")
413
416
  for record in self.records:
@@ -45,7 +45,7 @@ def is_legacy_policy_value(value: object) -> bool:
45
45
 
46
46
 
47
47
  def validate_policy_record(kind: str, value: object) -> None:
48
- """Validate one schema-v3 structured policy evidence value."""
48
+ """Validate one structured policy evidence value."""
49
49
 
50
50
  outer = _exact_mapping(value, {"identity", "fact"}, kind)
51
51
  if not policy_value_within_budget(outer):