open-code-review-toolkit 0.4.2__tar.gz → 0.4.4__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 (57) hide show
  1. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/PKG-INFO +4 -2
  2. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/README.md +3 -1
  3. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/_version.py +2 -2
  4. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/__init__.py +4 -0
  5. open_code_review_toolkit-0.4.4/src/ocr_toolkit/evidence/ansible.py +400 -0
  6. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/collect.py +50 -6
  7. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/collectors.py +107 -4
  8. open_code_review_toolkit-0.4.4/src/ocr_toolkit/evidence/coverage.py +87 -0
  9. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/mcp.py +30 -7
  10. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/model.py +168 -0
  11. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/project.py +12 -0
  12. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/store.py +52 -2
  13. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/formatting.py +138 -70
  14. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/gitlab.py +6 -5
  15. open_code_review_toolkit-0.4.4/src/ocr_toolkit/posting/result.py +210 -0
  16. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/workflow.py +218 -80
  17. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/preflight.py +1 -1
  18. open_code_review_toolkit-0.4.4/src/ocr_toolkit/result_contract.py +291 -0
  19. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/review_runner.py +25 -23
  20. open_code_review_toolkit-0.4.2/src/ocr_toolkit/evidence/ansible.py +0 -193
  21. open_code_review_toolkit-0.4.2/src/ocr_toolkit/posting/result.py +0 -78
  22. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/.gitignore +0 -0
  23. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/LICENSE +0 -0
  24. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/pyproject.toml +0 -0
  25. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/__init__.py +0 -0
  26. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/cli.py +0 -0
  27. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/__init__.py +0 -0
  28. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/git.py +0 -0
  29. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/language.py +0 -0
  30. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/markdown.py +0 -0
  31. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/redaction.py +0 -0
  32. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/config_writer.py +0 -0
  33. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/configure.py +0 -0
  34. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/__main__.py +0 -0
  35. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/ansible_requirements.py +0 -0
  36. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/artifacts.py +0 -0
  37. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/categorize.py +0 -0
  38. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/composer_manifests.py +0 -0
  39. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/go_manifests.py +0 -0
  40. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
  41. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/invocation.py +0 -0
  42. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/javascript_manifests.py +0 -0
  43. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/manifest_model.py +0 -0
  44. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/python_manifests.py +0 -0
  45. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/repository.py +0 -0
  46. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/mcp_config.py +0 -0
  47. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/ocr_result.py +0 -0
  48. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/__init__.py +0 -0
  49. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/__main__.py +0 -0
  50. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/comments.py +0 -0
  51. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/markers.py +0 -0
  52. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/payloads.py +0 -0
  53. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/settings.py +0 -0
  54. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/snapshot.py +0 -0
  55. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/providers/__init__.py +0 -0
  56. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/providers/gitlab.py +0 -0
  57. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -226,6 +226,8 @@ Description-Content-Type: text/markdown
226
226
 
227
227
  # Open Code Review Toolkit
228
228
 
229
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13906/badge)](https://www.bestpractices.dev/projects/13906)
230
+
229
231
  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.
230
232
 
231
233
  > [!NOTE]
@@ -241,7 +243,7 @@ ocr --version
241
243
  ocr-ci --help
242
244
  ```
243
245
 
244
- The current compatibility target is OCR `1.8.3`. CI should pin the release and verify its published checksum before execution.
246
+ The current compatibility target is OCR `1.8.6`. CI should pin the release and verify its published checksum before execution.
245
247
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
246
248
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
247
249
 
@@ -1,5 +1,7 @@
1
1
  # Open Code Review Toolkit
2
2
 
3
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13906/badge)](https://www.bestpractices.dev/projects/13906)
4
+
3
5
  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.
4
6
 
5
7
  > [!NOTE]
@@ -15,7 +17,7 @@ ocr --version
15
17
  ocr-ci --help
16
18
  ```
17
19
 
18
- The current compatibility target is OCR `1.8.3`. CI should pin the release and verify its published checksum before execution.
20
+ The current compatibility target is OCR `1.8.6`. CI should pin the release and verify its published checksum before execution.
19
21
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
20
22
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
21
23
 
@@ -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.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.4.4'
22
+ __version_tuple__ = version_tuple = (0, 4, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,8 @@
2
2
 
3
3
  from ocr_toolkit.evidence.model import (
4
4
  Confidence,
5
+ CoverageRecord,
6
+ CoverageState,
5
7
  EvidenceDelta,
6
8
  EvidenceRecord,
7
9
  EvidenceSnapshot,
@@ -19,6 +21,8 @@ from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError, Eviden
19
21
 
20
22
  __all__ = [
21
23
  "Confidence",
24
+ "CoverageRecord",
25
+ "CoverageState",
22
26
  "EvidenceDelta",
23
27
  "EvidenceRecord",
24
28
  "EvidenceSnapshot",
@@ -0,0 +1,400 @@
1
+ """Derive bounded Ansible topology facts from immutable repository blobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+
9
+ from ocr_toolkit.evidence.coverage import CoverageObservation
10
+ from ocr_toolkit.evidence.model import CoverageState
11
+
12
+ MAX_TOPOLOGY_ITEMS = 256
13
+ PLAYBOOK_KEY_RE = re.compile(r"^(?P<indent>\s*)[\"']?(?P<key>hosts|import_playbook)[\"']?\s*:")
14
+ INI_GROUP_RE = re.compile(r"^\s*\[(?P<group>[^]#:]+)(?::children|:vars)?]\s*$")
15
+ YAML_GROUP_RE = re.compile(
16
+ r"^(?P<indent>\s*)(?P<quote>[\"']?)(?P<group>[A-Za-z0-9_.:-]+)(?P=quote):"
17
+ r"(?:\s*\{\s*})?\s*$"
18
+ )
19
+ YAML_CHILDREN_RE = re.compile(r"^(?P<indent>\s*)children:\s*$")
20
+ DYNAMIC_PLUGIN_RE = re.compile(r"^[\"']?plugin[\"']?\s*:\s*(?P<value>[^#\r\n]+?)\s*$", re.MULTILINE)
21
+ ROLE_EXTENSIONS = {"", ".json", ".yaml", ".yml"}
22
+ UNSUPPORTED_STATIC_YAML_RE = re.compile(r"(?:\{\{|{%|&[A-Za-z]|\*[A-Za-z]|<<\s*:)")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class AnsibleTopologyFact:
27
+ """Describe one normalized Ansible path or inventory-group fact."""
28
+
29
+ kind: str
30
+ identity: str
31
+ value: dict[str, str]
32
+
33
+
34
+ def topology_candidate(path: str, *, executable: bool = False) -> bool:
35
+ """Return whether a normalized path can contribute Ansible topology."""
36
+
37
+ parts = PurePosixPath(path).parts
38
+ if not parts:
39
+ return False
40
+ name = parts[-1].casefold()
41
+ return (
42
+ (len(parts) == 1 and name.endswith((".yml", ".yaml")))
43
+ or _role_surface(path) is not None
44
+ or _is_inventory_path(path, executable=executable)
45
+ )
46
+
47
+
48
+ def _role_surface(path: str) -> tuple[str, str] | None:
49
+ """Return role name and supported topology surface for a canonical role path."""
50
+
51
+ parts = PurePosixPath(path).parts
52
+ folded = tuple(part.casefold() for part in parts)
53
+ if (
54
+ len(parts) < 4
55
+ or folded[0] != "roles"
56
+ or folded[2]
57
+ not in {
58
+ "meta",
59
+ "defaults",
60
+ "vars",
61
+ }
62
+ ):
63
+ return None
64
+ canonical_file = len(parts) == 4 and folded[3] in {
65
+ "main",
66
+ "main.json",
67
+ "main.yml",
68
+ "main.yaml",
69
+ }
70
+ recursive_main = (
71
+ len(parts) > 4
72
+ and folded[2] in {"defaults", "vars"}
73
+ and folded[3] == "main"
74
+ and _supported_role_main_descendant(parts[4:])
75
+ )
76
+ if canonical_file or recursive_main:
77
+ surface = "metadata" if folded[2] == "meta" else folded[2]
78
+ return parts[1], surface
79
+ return None
80
+
81
+
82
+ def _supported_role_main_descendant(parts: tuple[str, ...]) -> bool:
83
+ """Mirror Ansible 2.17-2.21/devel recursive vars-file selection."""
84
+
85
+ if not parts:
86
+ return False
87
+ if any(part.startswith(".") or part.endswith("~") for part in parts):
88
+ return False
89
+ if any(PurePosixPath(part).suffix for part in parts[:-1]):
90
+ return False
91
+ return PurePosixPath(parts[-1]).suffix.casefold() in ROLE_EXTENSIONS
92
+
93
+
94
+ def _is_inventory_path(path: str, *, executable: bool = False) -> bool:
95
+ """Recognize conventional inventory paths while excluding variable payloads."""
96
+
97
+ pure = PurePosixPath(path)
98
+ parts = pure.parts
99
+ if not parts:
100
+ return False
101
+ name = parts[-1].casefold()
102
+ folded = tuple(part.casefold() for part in parts)
103
+ if "group_vars" in folded or "host_vars" in folded:
104
+ return False
105
+ conventional = name in {
106
+ "hosts",
107
+ "hosts.ini",
108
+ "hosts.yml",
109
+ "hosts.yaml",
110
+ "inventory",
111
+ "inventory.ini",
112
+ "inventory.yml",
113
+ "inventory.yaml",
114
+ } or (
115
+ ("inventory" in folded or "inventories" in folded)
116
+ and pure.suffix.casefold() in {"", ".ini", ".yml", ".yaml"}
117
+ )
118
+ return conventional or (executable and ("inventory" in folded or "inventories" in folded))
119
+
120
+
121
+ def inventory_scope(path: str) -> str:
122
+ """Return the conservative directory scope for one inventory source."""
123
+
124
+ parent = PurePosixPath(path).parent.as_posix()
125
+ return "." if parent == "." else parent
126
+
127
+
128
+ def role_coverage_scope(path: str) -> tuple[str, str] | None:
129
+ """Return coverage domain and role loader scope for defaults/vars paths."""
130
+
131
+ parts = PurePosixPath(path).parts
132
+ folded = tuple(part.casefold() for part in parts)
133
+ if len(parts) < 4 or folded[0] != "roles" or folded[2] not in {"defaults", "vars"}:
134
+ return None
135
+ if _role_surface(path) is None:
136
+ return None
137
+ return f"role.{folded[2]}", "/".join((*parts[:3], "main"))
138
+
139
+
140
+ def selected_role_paths(paths: tuple[str, ...]) -> frozenset[str]:
141
+ """Select the role files Ansible loads, including recursive main directories."""
142
+
143
+ grouped: dict[tuple[str, str], set[str]] = {}
144
+ for path in paths:
145
+ role_surface = _role_surface(path)
146
+ if role_surface is None:
147
+ continue
148
+ role, name = role_surface
149
+ grouped.setdefault((role, name), set()).add(path)
150
+ selected: set[str] = set()
151
+ for (role, surface), candidates in grouped.items():
152
+ directory = "meta" if surface == "metadata" else surface
153
+ prefix = f"roles/{role}/{directory}"
154
+ for name in ("main.yml", "main.yaml", "main.json", "main"):
155
+ candidate = f"{prefix}/{name}"
156
+ if candidate in candidates:
157
+ selected.add(candidate)
158
+ break
159
+ else:
160
+ selected.update(path for path in candidates if path.startswith(f"{prefix}/main/"))
161
+ return frozenset(selected)
162
+
163
+
164
+ def _is_root_playbook(path: str, text: str) -> bool:
165
+ """Recognize a root-level playbook from bounded YAML structure signals."""
166
+
167
+ pure = PurePosixPath(path)
168
+ if len(pure.parts) != 1 or pure.suffix.casefold() not in {".yml", ".yaml"}:
169
+ return False
170
+ top_level_list_item = False
171
+ play_child_indent: int | None = None
172
+ for raw_line in text.splitlines():
173
+ line = raw_line.rstrip()
174
+ stripped = line.strip()
175
+ if not stripped or stripped.startswith("#") or stripped in {"---", "..."}:
176
+ continue
177
+ indent = len(line) - len(line.lstrip(" "))
178
+ if indent == 0 and stripped.startswith("-"):
179
+ top_level_list_item = True
180
+ play_child_indent = None
181
+ if PLAYBOOK_KEY_RE.match(stripped[1:].lstrip()):
182
+ return True
183
+ continue
184
+ if indent == 0:
185
+ top_level_list_item = False
186
+ play_child_indent = None
187
+ continue
188
+ if not top_level_list_item:
189
+ continue
190
+ if play_child_indent is None:
191
+ play_child_indent = indent
192
+ if indent == play_child_indent and PLAYBOOK_KEY_RE.match(stripped):
193
+ return True
194
+ return False
195
+
196
+
197
+ def _inventory_groups(text: str) -> tuple[tuple[str, ...], bool]:
198
+ """Extract bounded INI and YAML inventory group names without a YAML runtime."""
199
+
200
+ groups: set[str] = set()
201
+ children_indent: int | None = None
202
+ group_indent: int | None = None
203
+ top_level_group: str | None = None
204
+ for raw_line in text.splitlines():
205
+ ini = INI_GROUP_RE.match(raw_line)
206
+ if ini:
207
+ groups.add(ini.group("group").strip())
208
+ if len(groups) >= MAX_TOPOLOGY_ITEMS:
209
+ return tuple(sorted(groups)), True
210
+ continue
211
+ children = YAML_CHILDREN_RE.match(raw_line)
212
+ if children:
213
+ children_indent = len(children.group("indent"))
214
+ group_indent = None
215
+ continue
216
+ yaml_group = YAML_GROUP_RE.match(raw_line)
217
+ if yaml_group and len(yaml_group.group("indent")) == 0:
218
+ group = yaml_group.group("group")
219
+ top_level_group = group if group not in {"all", "ungrouped"} else None
220
+ if top_level_group is not None and re.search(r"\{\s*\}\s*$", raw_line):
221
+ groups.add(top_level_group)
222
+ if len(groups) >= MAX_TOPOLOGY_ITEMS:
223
+ return tuple(sorted(groups)), True
224
+ children_indent = None
225
+ group_indent = None
226
+ continue
227
+ if (
228
+ yaml_group
229
+ and top_level_group is not None
230
+ and yaml_group.group("group") in {"hosts", "children", "vars"}
231
+ ):
232
+ groups.add(top_level_group)
233
+ if yaml_group.group("group") == "children":
234
+ children_indent = len(yaml_group.group("indent"))
235
+ group_indent = None
236
+ continue
237
+ if yaml_group and children_indent is not None:
238
+ indent = len(yaml_group.group("indent"))
239
+ if indent <= children_indent:
240
+ children_indent = None
241
+ group_indent = None
242
+ else:
243
+ # YAML indentation width is repository-defined; the first key
244
+ # below children establishes the sibling group depth.
245
+ if group_indent is None:
246
+ group_indent = indent
247
+ if indent == group_indent:
248
+ groups.add(yaml_group.group("group"))
249
+ if len(groups) >= MAX_TOPOLOGY_ITEMS:
250
+ return tuple(sorted(groups)), True
251
+ return tuple(sorted(groups)), False
252
+
253
+
254
+ def _dynamic_inventory(text: str) -> bool:
255
+ """Recognize a scalar plugin declaration without resolving its value."""
256
+
257
+ match = DYNAMIC_PLUGIN_RE.search(text)
258
+ if match is None:
259
+ return False
260
+ value = match.group("value").strip().strip("\"'")
261
+ return bool(value)
262
+
263
+
264
+ def _static_inventory_supported(path: str, text: str) -> bool:
265
+ """Recognize the closed static syntax for which group enumeration is complete."""
266
+
267
+ if "\t" in text or "\x00" in text:
268
+ return False
269
+ logical = [
270
+ line.strip()
271
+ for line in text.splitlines()
272
+ if line.strip() and not line.lstrip().startswith("#")
273
+ ]
274
+ if not logical:
275
+ return True
276
+ ini_source = PurePosixPath(path).suffix.casefold() == ".ini" or logical[0].startswith("[")
277
+ if ini_source:
278
+ return all(
279
+ not line.startswith("[") or INI_GROUP_RE.match(line) is not None for line in logical
280
+ )
281
+ if UNSUPPORTED_STATIC_YAML_RE.search(text):
282
+ return False
283
+ for line in logical:
284
+ if line in {"---", "..."} or line.startswith("-"):
285
+ continue
286
+ if ":" not in line:
287
+ return False
288
+ key, value = (part.strip() for part in line.split(":", 1))
289
+ if key.startswith(('"', "'")) and not key.endswith(key[0]):
290
+ return False
291
+ if (
292
+ value
293
+ and value != "{}"
294
+ and any(marker in value for marker in ("[", "]", "{", "}", "|", ">"))
295
+ ):
296
+ return False
297
+ return True
298
+
299
+
300
+ def topology_coverage(
301
+ path: str, text: str, *, executable: bool = False
302
+ ) -> tuple[str, str, CoverageObservation] | None:
303
+ """Return one source-level completeness observation for aggregation."""
304
+
305
+ if _is_inventory_path(path, executable=executable):
306
+ groups, truncated = _inventory_groups(text)
307
+ if executable or _dynamic_inventory(text):
308
+ return (
309
+ "inventory.groups",
310
+ inventory_scope(path),
311
+ CoverageObservation(
312
+ CoverageState.RUNTIME_DEPENDENT,
313
+ "executable-source" if executable else "dynamic-source",
314
+ positive=bool(groups),
315
+ ),
316
+ )
317
+ supported = _static_inventory_supported(path, text)
318
+ state = (
319
+ CoverageState.PARTIAL
320
+ if truncated or (not supported and bool(groups))
321
+ else CoverageState.COMPLETE
322
+ if supported
323
+ else CoverageState.UNAVAILABLE
324
+ )
325
+ return (
326
+ "inventory.groups",
327
+ inventory_scope(path),
328
+ CoverageObservation(
329
+ state,
330
+ "group-limit"
331
+ if truncated
332
+ else "static-source"
333
+ if supported
334
+ else "unsupported-static-syntax",
335
+ positive=bool(groups) or supported,
336
+ ),
337
+ )
338
+ role_scope = role_coverage_scope(path)
339
+ if role_scope is not None:
340
+ return (
341
+ role_scope[0],
342
+ role_scope[1],
343
+ CoverageObservation(CoverageState.COMPLETE, "bounded-role-source", positive=True),
344
+ )
345
+ return None
346
+
347
+
348
+ def collect_topology(
349
+ path: str, text: str, *, executable: bool = False
350
+ ) -> tuple[AnsibleTopologyFact, ...]:
351
+ """Collect typed Ansible topology facts for one immutable path and blob."""
352
+
353
+ facts = []
354
+ if _is_root_playbook(path, text):
355
+ facts.append(AnsibleTopologyFact("ansible.playbook", path, {"path": path, "scope": "root"}))
356
+ role_surface = _role_surface(path)
357
+ if role_surface is not None:
358
+ role, surface = role_surface
359
+ facts.append(
360
+ AnsibleTopologyFact(
361
+ f"ansible.role_{surface}",
362
+ path,
363
+ {"role": role, "path": path},
364
+ )
365
+ )
366
+ if _is_inventory_path(path, executable=executable):
367
+ groups, truncated = _inventory_groups(text)
368
+ source_type = (
369
+ "executable" if executable else "dynamic" if _dynamic_inventory(text) else "static"
370
+ )
371
+ static_supported = _static_inventory_supported(path, text)
372
+ group_coverage = (
373
+ "runtime-dependent"
374
+ if source_type in {"dynamic", "executable"}
375
+ else "partial"
376
+ if truncated or (not static_supported and bool(groups))
377
+ else "unavailable"
378
+ if not static_supported
379
+ else "complete"
380
+ )
381
+ facts.append(
382
+ AnsibleTopologyFact(
383
+ "ansible.inventory",
384
+ path,
385
+ {
386
+ "path": path,
387
+ "source_type": source_type,
388
+ "group_coverage": group_coverage,
389
+ },
390
+ )
391
+ )
392
+ facts.extend(
393
+ AnsibleTopologyFact(
394
+ "ansible.inventory_group",
395
+ f"{path}:{group}",
396
+ {"path": path, "group": group},
397
+ )
398
+ for group in groups
399
+ )
400
+ return tuple(facts)
@@ -7,7 +7,8 @@ from pathlib import Path
7
7
 
8
8
  from ocr_toolkit.evidence.categorize import categorize_paths
9
9
  from ocr_toolkit.evidence.collectors import collect_ref_facts, fact_deltas
10
- from ocr_toolkit.evidence.model import EvidenceRecord, RefRole, TrustClass
10
+ from ocr_toolkit.evidence.coverage import coverage_deltas
11
+ from ocr_toolkit.evidence.model import EvidenceRecord, EvidenceSnapshot, RefRole, TrustClass
11
12
  from ocr_toolkit.evidence.repository import (
12
13
  GitRepositoryReader,
13
14
  RepositoryEvidenceError,
@@ -54,17 +55,42 @@ def collect_repository_evidence(
54
55
  head = build_file_snapshot(reader, head_sha, RefRole.HEAD, paths=changed)
55
56
  base_paths = {record.source_path for record in base.records}
56
57
  head_paths = {record.source_path for record in head.records}
57
- store = EvidenceStore(base=base, head=head, deltas=file_deltas(base, head))
58
+ base_coverage = []
59
+ head_coverage = []
58
60
  base_facts, base_fact_diagnostics = collect_ref_facts(
59
- reader, base_sha, RefRole.BASE, changed_paths=changed
61
+ reader,
62
+ base_sha,
63
+ RefRole.BASE,
64
+ changed_paths=changed,
65
+ coverage_sink=base_coverage,
60
66
  )
61
67
  head_facts, head_fact_diagnostics = collect_ref_facts(
62
- reader, head_sha, RefRole.HEAD, changed_paths=changed
68
+ reader,
69
+ head_sha,
70
+ RefRole.HEAD,
71
+ changed_paths=changed,
72
+ coverage_sink=head_coverage,
73
+ )
74
+ base = EvidenceSnapshot(
75
+ base.ref,
76
+ base.commit_sha,
77
+ base.records,
78
+ diagnostics=base.diagnostics,
79
+ coverage=tuple(base_coverage),
80
+ )
81
+ head = EvidenceSnapshot(
82
+ head.ref,
83
+ head.commit_sha,
84
+ head.records,
85
+ diagnostics=head.diagnostics,
86
+ coverage=tuple(head_coverage),
63
87
  )
88
+ all_coverage = tuple((*base.coverage, *head.coverage))
89
+ store = EvidenceStore(base=base, head=head, deltas=file_deltas(base, head))
64
90
  typed_facts = [*base_facts, *head_facts]
65
91
  store.deltas = tuple(
66
92
  sorted(
67
- (*store.deltas, *fact_deltas(typed_facts)),
93
+ (*store.deltas, *fact_deltas(typed_facts), *coverage_deltas(all_coverage)),
68
94
  key=lambda item: (item.kind, item.component, item.identity),
69
95
  )
70
96
  )
@@ -77,8 +103,26 @@ def collect_repository_evidence(
77
103
  raise EvidenceStoreError(
78
104
  "repository snapshot records exceed the configured evidence store limits"
79
105
  )
80
- for record in typed_facts:
106
+ for coverage in all_coverage:
107
+ if not store.add_coverage(coverage):
108
+ raise EvidenceStoreError(
109
+ "repository snapshot coverage exceeds the configured evidence store limits"
110
+ )
111
+ ordered_typed_facts = sorted(
112
+ typed_facts,
113
+ key=lambda record: (
114
+ 0 if record.component == "ansible" and record.kind.startswith("ansible.") else 1,
115
+ record.kind,
116
+ record.source_path,
117
+ record.id,
118
+ ),
119
+ )
120
+ for record in ordered_typed_facts:
81
121
  if not store.add(record):
122
+ if record.component == "ansible" and record.kind.startswith("ansible."):
123
+ raise EvidenceStoreError(
124
+ "Ansible topology facts exceed the atomic evidence store limits"
125
+ )
82
126
  store.add_diagnostic("typed evidence was truncated by store limits")
83
127
  break
84
128
  categories = categorize_paths(list(changed))