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.
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/PKG-INFO +4 -2
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/README.md +3 -1
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/_version.py +2 -2
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/__init__.py +4 -0
- open_code_review_toolkit-0.4.4/src/ocr_toolkit/evidence/ansible.py +400 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/collect.py +50 -6
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/collectors.py +107 -4
- open_code_review_toolkit-0.4.4/src/ocr_toolkit/evidence/coverage.py +87 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/mcp.py +30 -7
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/model.py +168 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/project.py +12 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/store.py +52 -2
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/formatting.py +138 -70
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/gitlab.py +6 -5
- open_code_review_toolkit-0.4.4/src/ocr_toolkit/posting/result.py +210 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/workflow.py +218 -80
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/preflight.py +1 -1
- open_code_review_toolkit-0.4.4/src/ocr_toolkit/result_contract.py +291 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/review_runner.py +25 -23
- open_code_review_toolkit-0.4.2/src/ocr_toolkit/evidence/ansible.py +0 -193
- open_code_review_toolkit-0.4.2/src/ocr_toolkit/posting/result.py +0 -78
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/.gitignore +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/LICENSE +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/pyproject.toml +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/__init__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/cli.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/__init__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/git.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/language.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/markdown.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/common/redaction.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/config_writer.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/configure.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/__main__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/ansible_requirements.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/artifacts.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/categorize.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/composer_manifests.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/go_manifests.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/invocation.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/javascript_manifests.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/manifest_model.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/python_manifests.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/evidence/repository.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/mcp_config.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/ocr_result.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/__init__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/__main__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/comments.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/markers.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/payloads.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/settings.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/posting/snapshot.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/providers/__init__.py +0 -0
- {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/providers/gitlab.py +0 -0
- {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.
|
|
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
|
+
[](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.
|
|
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
|
+
[](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.
|
|
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
|
|
{open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.4}/src/ocr_toolkit/_version.py
RENAMED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
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.
|
|
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
|
-
|
|
58
|
+
base_coverage = []
|
|
59
|
+
head_coverage = []
|
|
58
60
|
base_facts, base_fact_diagnostics = collect_ref_facts(
|
|
59
|
-
reader,
|
|
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,
|
|
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
|
|
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))
|