PTSIP 0.3.1__py3-none-any.whl
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.
- ptsip/__init__.py +3 -0
- ptsip/__main__.py +4 -0
- ptsip/app/__init__.py +5 -0
- ptsip/app/client.py +53 -0
- ptsip/app/github_client.py +200 -0
- ptsip/app/server.py +130 -0
- ptsip/app/service.py +285 -0
- ptsip/app/store.py +233 -0
- ptsip/artifact_evidence.py +150 -0
- ptsip/build_resolution.py +298 -0
- ptsip/clarification/__init__.py +6 -0
- ptsip/clarification/generator.py +120 -0
- ptsip/clarification/generator_core.py +98 -0
- ptsip/clarification/i18n.py +106 -0
- ptsip/clarification/model.py +33 -0
- ptsip/clarification/render.py +78 -0
- ptsip/clarification/resolution/__init__.py +29 -0
- ptsip/clarification/resolution/model.py +33 -0
- ptsip/clarification/resolution/parser.py +56 -0
- ptsip/clarification/resolution/profile_projection.py +198 -0
- ptsip/clarification/resolution/resolver.py +30 -0
- ptsip/clarification/transports/__init__.py +1 -0
- ptsip/clarification/transports/github_issue.py +162 -0
- ptsip/cli.py +402 -0
- ptsip/conformance.py +531 -0
- ptsip/conformance_audit.py +143 -0
- ptsip/conformance_engine.py +396 -0
- ptsip/constants.py +9 -0
- ptsip/doctor.py +33 -0
- ptsip/inspection/__init__.py +1 -0
- ptsip/inspection/components.py +74 -0
- ptsip/inspection/dependencies.py +496 -0
- ptsip/inspection/dependencies_030.py +69 -0
- ptsip/inspection/dotnet.py +188 -0
- ptsip/inspection/go.py +157 -0
- ptsip/inspection/inventory.py +161 -0
- ptsip/inspection/javascript.py +376 -0
- ptsip/inspection/lexing.py +121 -0
- ptsip/inspection/source_adapters.py +60 -0
- ptsip/lifecycle_evidence.py +245 -0
- ptsip/model.py +105 -0
- ptsip/pilot/__init__.py +1 -0
- ptsip/pilot/runner.py +170 -0
- ptsip/repository/__init__.py +1 -0
- ptsip/repository/discover.py +68 -0
- ptsip/repository/remote.py +54 -0
- ptsip/repository/snapshot.py +173 -0
- ptsip/review_evidence.py +406 -0
- ptsip/spec_identity.py +22 -0
- ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
- ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
- ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
- ptsip/specdata/ptsip-profile.schema.json +310 -0
- ptsip/specdata/ptsip-registry.yaml +189 -0
- ptsip/storage/__init__.py +1 -0
- ptsip/storage/local_state.py +32 -0
- ptsip/validation/__init__.py +1 -0
- ptsip/validation/components.py +164 -0
- ptsip/validation/profile.py +172 -0
- ptsip/validation/rules.py +163 -0
- ptsip-0.3.1.dist-info/METADATA +338 -0
- ptsip-0.3.1.dist-info/RECORD +66 -0
- ptsip-0.3.1.dist-info/WHEEL +5 -0
- ptsip-0.3.1.dist-info/entry_points.txt +3 -0
- ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
- ptsip-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .validation.components import ComponentPartition, partition_components
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ManifestEvidence:
|
|
18
|
+
component_id: str
|
|
19
|
+
classification: str
|
|
20
|
+
path: str
|
|
21
|
+
kind: str
|
|
22
|
+
direct_dependencies: tuple[str, ...]
|
|
23
|
+
complete: bool
|
|
24
|
+
issue: str | None = None
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict[str, object]:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BuildResolutionResult:
|
|
32
|
+
status: str
|
|
33
|
+
reason: str | None
|
|
34
|
+
manifests: tuple[ManifestEvidence, ...]
|
|
35
|
+
blocking_gaps: tuple[dict[str, object], ...]
|
|
36
|
+
|
|
37
|
+
def as_dict(self) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"status": self.status,
|
|
40
|
+
"reason": self.reason,
|
|
41
|
+
"manifests": [item.as_dict() for item in self.manifests],
|
|
42
|
+
"blocking_gaps": list(self.blocking_gaps),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_requirement(value: str) -> str | None:
|
|
47
|
+
match = _REQUIREMENT_NAME_RE.match(value)
|
|
48
|
+
return match.group(1) if match else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_pyproject(path: Path) -> tuple[tuple[str, ...], str | None]:
|
|
52
|
+
try:
|
|
53
|
+
payload = tomllib.loads(path.read_text(encoding="utf-8-sig"))
|
|
54
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
55
|
+
return (), str(exc)
|
|
56
|
+
project = payload.get("project", {}) if isinstance(payload, dict) else {}
|
|
57
|
+
if not isinstance(project, dict):
|
|
58
|
+
return (), None
|
|
59
|
+
values: list[str] = []
|
|
60
|
+
groups: list[object] = [project.get("dependencies", [])]
|
|
61
|
+
optional = project.get("optional-dependencies", {})
|
|
62
|
+
if isinstance(optional, dict):
|
|
63
|
+
groups.extend(optional.values())
|
|
64
|
+
for group in groups:
|
|
65
|
+
if not isinstance(group, list):
|
|
66
|
+
continue
|
|
67
|
+
for item in group:
|
|
68
|
+
if isinstance(item, str):
|
|
69
|
+
name = _normalize_requirement(item)
|
|
70
|
+
if name:
|
|
71
|
+
values.append(name)
|
|
72
|
+
return tuple(sorted(set(values))), None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_requirements(path: Path) -> tuple[tuple[str, ...], str | None]:
|
|
76
|
+
try:
|
|
77
|
+
lines = path.read_text(encoding="utf-8-sig").splitlines()
|
|
78
|
+
except (OSError, UnicodeError) as exc:
|
|
79
|
+
return (), str(exc)
|
|
80
|
+
values: list[str] = []
|
|
81
|
+
unresolved_includes: list[str] = []
|
|
82
|
+
for line in lines:
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
if not stripped or stripped.startswith("#"):
|
|
85
|
+
continue
|
|
86
|
+
if stripped.startswith(("-r", "--requirement", "-c", "--constraint")):
|
|
87
|
+
unresolved_includes.append(stripped)
|
|
88
|
+
continue
|
|
89
|
+
if stripped.startswith(("-e", "--editable")):
|
|
90
|
+
unresolved_includes.append(stripped)
|
|
91
|
+
continue
|
|
92
|
+
name = _normalize_requirement(stripped)
|
|
93
|
+
if name:
|
|
94
|
+
values.append(name)
|
|
95
|
+
issue = None
|
|
96
|
+
if unresolved_includes:
|
|
97
|
+
issue = "Nested/editable requirement declarations require additional resolution: " + ", ".join(unresolved_includes[:10])
|
|
98
|
+
return tuple(sorted(set(values))), issue
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_package_json(path: Path) -> tuple[tuple[str, ...], str | None]:
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
104
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
105
|
+
return (), str(exc)
|
|
106
|
+
if not isinstance(payload, dict):
|
|
107
|
+
return (), "package.json root is not an object"
|
|
108
|
+
values: set[str] = set()
|
|
109
|
+
for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
110
|
+
group = payload.get(section, {})
|
|
111
|
+
if isinstance(group, dict):
|
|
112
|
+
values.update(str(name) for name in group if isinstance(name, str))
|
|
113
|
+
return tuple(sorted(values)), None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_csproj(path: Path) -> tuple[tuple[str, ...], str | None]:
|
|
117
|
+
try:
|
|
118
|
+
tree = ET.parse(path)
|
|
119
|
+
except (OSError, ET.ParseError) as exc:
|
|
120
|
+
return (), str(exc)
|
|
121
|
+
values: set[str] = set()
|
|
122
|
+
for element in tree.iter():
|
|
123
|
+
if element.tag.endswith("PackageReference"):
|
|
124
|
+
include = element.attrib.get("Include") or element.attrib.get("Update")
|
|
125
|
+
if include:
|
|
126
|
+
values.add(include)
|
|
127
|
+
elif element.tag.endswith("ProjectReference"):
|
|
128
|
+
include = element.attrib.get("Include")
|
|
129
|
+
if include:
|
|
130
|
+
values.add(include.replace("\\", "/"))
|
|
131
|
+
return tuple(sorted(values)), None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_go_mod(path: Path) -> tuple[tuple[str, ...], str | None]:
|
|
135
|
+
try:
|
|
136
|
+
lines = path.read_text(encoding="utf-8-sig").splitlines()
|
|
137
|
+
except (OSError, UnicodeError) as exc:
|
|
138
|
+
return (), str(exc)
|
|
139
|
+
values: set[str] = set()
|
|
140
|
+
in_require = False
|
|
141
|
+
for line in lines:
|
|
142
|
+
stripped = line.split("//", 1)[0].strip()
|
|
143
|
+
if not stripped:
|
|
144
|
+
continue
|
|
145
|
+
if stripped == "require (":
|
|
146
|
+
in_require = True
|
|
147
|
+
continue
|
|
148
|
+
if in_require and stripped == ")":
|
|
149
|
+
in_require = False
|
|
150
|
+
continue
|
|
151
|
+
if stripped.startswith("require "):
|
|
152
|
+
fields = stripped[len("require ") :].split()
|
|
153
|
+
if fields:
|
|
154
|
+
values.add(fields[0])
|
|
155
|
+
elif in_require:
|
|
156
|
+
fields = stripped.split()
|
|
157
|
+
if fields:
|
|
158
|
+
values.add(fields[0])
|
|
159
|
+
return tuple(sorted(values)), None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_manifest(path: Path) -> tuple[str, tuple[str, ...], str | None]:
|
|
163
|
+
name = path.name.lower()
|
|
164
|
+
suffix = path.suffix.lower()
|
|
165
|
+
if name == "pyproject.toml":
|
|
166
|
+
deps, issue = _parse_pyproject(path)
|
|
167
|
+
return "python-pyproject", deps, issue
|
|
168
|
+
if name.startswith("requirements") and suffix in {".txt", ".in"}:
|
|
169
|
+
deps, issue = _parse_requirements(path)
|
|
170
|
+
return "python-requirements", deps, issue
|
|
171
|
+
if name == "package.json":
|
|
172
|
+
deps, issue = _parse_package_json(path)
|
|
173
|
+
return "npm-package", deps, issue
|
|
174
|
+
if suffix == ".csproj":
|
|
175
|
+
deps, issue = _parse_csproj(path)
|
|
176
|
+
return "dotnet-project", deps, issue
|
|
177
|
+
if name == "go.mod":
|
|
178
|
+
deps, issue = _parse_go_mod(path)
|
|
179
|
+
return "go-module", deps, issue
|
|
180
|
+
return "unsupported", (), "Unsupported build/dependency manifest type"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _gap(component_id: str, message: str, evidence_id: str) -> dict[str, object]:
|
|
184
|
+
return {
|
|
185
|
+
"id": f"build-resolution:{component_id}:{evidence_id}",
|
|
186
|
+
"blocking": True,
|
|
187
|
+
"rule_ids": ["PTSIP-BLD-001", "PTSIP-EVD-003"],
|
|
188
|
+
"evidence_ids": [f"build-resolution:{component_id}:{evidence_id}"],
|
|
189
|
+
"message": message,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _normalize_manifest_path(raw: str) -> str:
|
|
194
|
+
normalized = raw.replace("\\", "/")
|
|
195
|
+
while normalized.startswith("./"):
|
|
196
|
+
normalized = normalized[2:]
|
|
197
|
+
return normalized
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def evaluate_independent_build_resolution(
|
|
201
|
+
repository_root: str | Path,
|
|
202
|
+
components: list[dict[str, object]],
|
|
203
|
+
partition: ComponentPartition | None = None,
|
|
204
|
+
) -> BuildResolutionResult:
|
|
205
|
+
root = Path(repository_root).resolve()
|
|
206
|
+
partition = partition or partition_components(root, components)
|
|
207
|
+
owners = {assignment.path: assignment.component_id for assignment in partition.assignments}
|
|
208
|
+
classifications = {
|
|
209
|
+
str(component.get("id")): str(component.get("classification"))
|
|
210
|
+
for component in components
|
|
211
|
+
if component.get("id") and component.get("classification")
|
|
212
|
+
}
|
|
213
|
+
conflicted_paths = {conflict.path for conflict in partition.conflicts}
|
|
214
|
+
manifests: list[ManifestEvidence] = []
|
|
215
|
+
gaps: list[dict[str, object]] = []
|
|
216
|
+
manifest_users: dict[str, list[tuple[str, str]]] = {}
|
|
217
|
+
|
|
218
|
+
relevant_components = [
|
|
219
|
+
item for item in components if str(item.get("classification")) in {"PRODUCT", "TOOLCHAIN"}
|
|
220
|
+
]
|
|
221
|
+
if not relevant_components:
|
|
222
|
+
return BuildResolutionResult("BLOCKED", "NO_PRODUCT_OR_TOOLCHAIN_COMPONENTS", (), (_gap("<profile>", "No PRODUCT or TOOLCHAIN components are available for build-resolution evaluation.", "no-components"),))
|
|
223
|
+
|
|
224
|
+
for component in relevant_components:
|
|
225
|
+
component_id = str(component.get("id", ""))
|
|
226
|
+
classification = str(component.get("classification", ""))
|
|
227
|
+
declared = component.get("manifests", [])
|
|
228
|
+
manifest_paths = [str(item) for item in declared] if isinstance(declared, list) else []
|
|
229
|
+
if not manifest_paths:
|
|
230
|
+
gaps.append(
|
|
231
|
+
_gap(
|
|
232
|
+
component_id,
|
|
233
|
+
"Component does not declare a dependency/build manifest, so its direct dependency set cannot be determined independently.",
|
|
234
|
+
"manifest-missing",
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
usable = 0
|
|
240
|
+
for raw in manifest_paths:
|
|
241
|
+
normalized = _normalize_manifest_path(raw)
|
|
242
|
+
candidate = (root / normalized).resolve()
|
|
243
|
+
try:
|
|
244
|
+
inside = candidate.is_relative_to(root)
|
|
245
|
+
except (OSError, ValueError):
|
|
246
|
+
inside = False
|
|
247
|
+
if not inside or not candidate.is_file():
|
|
248
|
+
gaps.append(_gap(component_id, f"Declared manifest {raw!r} does not resolve to a repository file.", f"manifest-unresolved:{normalized}"))
|
|
249
|
+
continue
|
|
250
|
+
relative_path = candidate.relative_to(root).as_posix()
|
|
251
|
+
manifest_owner = owners.get(relative_path)
|
|
252
|
+
ownership_issue: str | None = None
|
|
253
|
+
if relative_path in conflicted_paths:
|
|
254
|
+
ownership_issue = "manifest ownership is conflicted across component selectors"
|
|
255
|
+
elif manifest_owner is None:
|
|
256
|
+
ownership_issue = "manifest is outside declared component ownership"
|
|
257
|
+
elif manifest_owner != component_id:
|
|
258
|
+
owner_plane = classifications.get(manifest_owner, "UNKNOWN")
|
|
259
|
+
ownership_issue = (
|
|
260
|
+
f"manifest is owned by component {manifest_owner!r} ({owner_plane}), not declaring component "
|
|
261
|
+
f"{component_id!r} ({classification})"
|
|
262
|
+
)
|
|
263
|
+
kind, dependencies, issue = _parse_manifest(candidate)
|
|
264
|
+
combined_issue = "; ".join(item for item in (ownership_issue, issue) if item) or None
|
|
265
|
+
evidence = ManifestEvidence(
|
|
266
|
+
component_id=component_id,
|
|
267
|
+
classification=classification,
|
|
268
|
+
path=relative_path,
|
|
269
|
+
kind=kind,
|
|
270
|
+
direct_dependencies=dependencies,
|
|
271
|
+
complete=combined_issue is None,
|
|
272
|
+
issue=combined_issue,
|
|
273
|
+
)
|
|
274
|
+
manifests.append(evidence)
|
|
275
|
+
manifest_users.setdefault(evidence.path, []).append((component_id, classification))
|
|
276
|
+
if combined_issue is not None:
|
|
277
|
+
gaps.append(_gap(component_id, f"Manifest {evidence.path!r} is not usable as independent build evidence: {combined_issue}", f"manifest-incomplete:{evidence.path}"))
|
|
278
|
+
else:
|
|
279
|
+
usable += 1
|
|
280
|
+
if usable == 0 and manifest_paths:
|
|
281
|
+
gaps.append(_gap(component_id, "No declared manifest produced a complete direct-dependency set.", "no-complete-manifest"))
|
|
282
|
+
|
|
283
|
+
for path, users in sorted(manifest_users.items()):
|
|
284
|
+
planes = {classification for _, classification in users}
|
|
285
|
+
if len(planes) <= 1:
|
|
286
|
+
continue
|
|
287
|
+
components_text = ", ".join(sorted(component_id for component_id, _ in users))
|
|
288
|
+
gaps.append(
|
|
289
|
+
_gap(
|
|
290
|
+
components_text.replace(", ", "+"),
|
|
291
|
+
f"The same manifest {path!r} is declared by PRODUCT and TOOLCHAIN components ({components_text}); this evidence does not prove independently determinable direct dependency sets.",
|
|
292
|
+
f"shared-cross-plane-manifest:{path}",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
status = "RAN" if not gaps else "BLOCKED"
|
|
297
|
+
reason = None if not gaps else "BUILD_RESOLUTION_EVIDENCE_INCOMPLETE"
|
|
298
|
+
return BuildResolutionResult(status, reason, tuple(manifests), tuple(gaps))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Deterministic human clarification support for unresolved architectural intent."""
|
|
2
|
+
|
|
3
|
+
from .generator import ClarificationAnalysis, analyze_clarifications
|
|
4
|
+
from .i18n import SUPPORTED_LANGUAGES, resolve_language
|
|
5
|
+
|
|
6
|
+
__all__ = ["ClarificationAnalysis", "SUPPORTED_LANGUAGES", "analyze_clarifications", "resolve_language"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from ..inspection.components import ComponentCandidate, discover_component_candidates
|
|
9
|
+
from ..inspection.dependencies_030 import scan_dependency_edges
|
|
10
|
+
from ..inspection.inventory import collect_inventory
|
|
11
|
+
from ..repository.discover import RepositoryInfo, discover_repository
|
|
12
|
+
from ..repository.snapshot import RepositorySnapshot, SnapshotComparison, capture_snapshot, compare_snapshots
|
|
13
|
+
from ..validation.profile import find_profile
|
|
14
|
+
from .generator_core import build_requests
|
|
15
|
+
from .model import ClarificationRequest
|
|
16
|
+
from .render import request_payload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ClarificationAnalysis:
|
|
21
|
+
repository: RepositoryInfo
|
|
22
|
+
before: RepositorySnapshot
|
|
23
|
+
after: RepositorySnapshot
|
|
24
|
+
comparison: SnapshotComparison
|
|
25
|
+
candidate_ids: tuple[str, ...]
|
|
26
|
+
requests: tuple[ClarificationRequest, ...]
|
|
27
|
+
profile_path: str | None
|
|
28
|
+
profile_parse_error: str | None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def status(self) -> str:
|
|
32
|
+
if not self.comparison.stable:
|
|
33
|
+
return "INVALIDATED"
|
|
34
|
+
if self.requests:
|
|
35
|
+
return "CLARIFICATION_REQUIRED"
|
|
36
|
+
return "NO_CLARIFICATION_REQUIRED"
|
|
37
|
+
|
|
38
|
+
def as_dict(self, language: str) -> dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"format": "ptsip-clarification/v1",
|
|
41
|
+
"status": self.status,
|
|
42
|
+
"language": language,
|
|
43
|
+
"inference": {
|
|
44
|
+
"mode": "DETERMINISTIC_RULES_ONLY",
|
|
45
|
+
"llm_calls": 0,
|
|
46
|
+
"speculative_classification": False,
|
|
47
|
+
},
|
|
48
|
+
"repository": self.repository.as_dict(),
|
|
49
|
+
"snapshot": {
|
|
50
|
+
"before": self.before.as_dict(),
|
|
51
|
+
"after": self.after.as_dict(),
|
|
52
|
+
"comparison": self.comparison.as_dict(),
|
|
53
|
+
},
|
|
54
|
+
"profile": {
|
|
55
|
+
"path": self.profile_path,
|
|
56
|
+
"parse_error": self.profile_parse_error,
|
|
57
|
+
},
|
|
58
|
+
"component_candidate_count": len(self.candidate_ids),
|
|
59
|
+
"clarification_count": len(self.requests),
|
|
60
|
+
"requests": [request_payload(item, language) for item in self.requests],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _declared_components(root: Path) -> tuple[list[dict[str, object]], str | None, str | None]:
|
|
65
|
+
profile = find_profile(root)
|
|
66
|
+
if profile is None:
|
|
67
|
+
return [], None, None
|
|
68
|
+
try:
|
|
69
|
+
payload = yaml.safe_load(profile.read_text(encoding="utf-8"))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
return [], str(profile), str(exc)
|
|
72
|
+
if not isinstance(payload, dict):
|
|
73
|
+
return [], str(profile), "profile root is not a mapping"
|
|
74
|
+
raw = payload.get("components")
|
|
75
|
+
if not isinstance(raw, list):
|
|
76
|
+
return [], str(profile), None
|
|
77
|
+
components = [item for item in raw if isinstance(item, dict)]
|
|
78
|
+
return components, str(profile), None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def analyze_clarifications(
|
|
82
|
+
path: str | Path = ".",
|
|
83
|
+
component_ids: list[str] | tuple[str, ...] | None = None,
|
|
84
|
+
) -> ClarificationAnalysis:
|
|
85
|
+
repo = discover_repository(path)
|
|
86
|
+
root = Path(repo.root).resolve()
|
|
87
|
+
before = capture_snapshot(root)
|
|
88
|
+
inventory = collect_inventory(root)
|
|
89
|
+
dependencies = scan_dependency_edges(root)
|
|
90
|
+
all_candidates = discover_component_candidates(root, inventory, dependencies)
|
|
91
|
+
candidate_ids = tuple(item.id for item in all_candidates)
|
|
92
|
+
|
|
93
|
+
selected = set(component_ids or ())
|
|
94
|
+
if selected:
|
|
95
|
+
unknown = sorted(selected - set(candidate_ids))
|
|
96
|
+
if unknown:
|
|
97
|
+
raise ValueError("Unknown component candidate(s): " + ", ".join(unknown))
|
|
98
|
+
candidates: list[ComponentCandidate] = [item for item in all_candidates if item.id in selected]
|
|
99
|
+
else:
|
|
100
|
+
candidates = all_candidates
|
|
101
|
+
|
|
102
|
+
declared, profile_path, profile_error = _declared_components(root)
|
|
103
|
+
identity = (
|
|
104
|
+
repo.remote.repository
|
|
105
|
+
if repo.remote and repo.remote.provider == "github" and repo.remote.repository
|
|
106
|
+
else repo.root
|
|
107
|
+
)
|
|
108
|
+
requests = build_requests(identity, candidates, declared)
|
|
109
|
+
after = capture_snapshot(root)
|
|
110
|
+
comparison = compare_snapshots(before, after)
|
|
111
|
+
return ClarificationAnalysis(
|
|
112
|
+
repository=repo,
|
|
113
|
+
before=before,
|
|
114
|
+
after=after,
|
|
115
|
+
comparison=comparison,
|
|
116
|
+
candidate_ids=candidate_ids,
|
|
117
|
+
requests=requests,
|
|
118
|
+
profile_path=profile_path,
|
|
119
|
+
profile_parse_error=profile_error,
|
|
120
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Iterable, Protocol
|
|
5
|
+
|
|
6
|
+
from .model import FIELD_ORDER, REASON_BY_FIELD, ClarificationRequest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CandidateLike(Protocol):
|
|
10
|
+
id: str
|
|
11
|
+
include: tuple[str, ...]
|
|
12
|
+
anchors: tuple[str, ...]
|
|
13
|
+
evidence_ids: tuple[str, ...]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_selector(value: str) -> str:
|
|
17
|
+
text = value.replace("\\", "/").strip()
|
|
18
|
+
while text.startswith("./"):
|
|
19
|
+
text = text[2:]
|
|
20
|
+
return text.strip("/")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _selector_covers(declared: str, candidate: str) -> bool:
|
|
24
|
+
declared = _normalize_selector(declared)
|
|
25
|
+
candidate = _normalize_selector(candidate)
|
|
26
|
+
if declared == candidate:
|
|
27
|
+
return True
|
|
28
|
+
if declared.endswith("/**"):
|
|
29
|
+
base = declared[:-3].rstrip("/")
|
|
30
|
+
candidate_base = candidate[:-3].rstrip("/") if candidate.endswith("/**") else candidate
|
|
31
|
+
return candidate_base == base or candidate_base.startswith(base + "/")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _covering_components(candidate: CandidateLike, components: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
36
|
+
found: list[dict[str, object]] = []
|
|
37
|
+
for component in components:
|
|
38
|
+
selectors = [str(item) for item in component.get("include", [])]
|
|
39
|
+
if any(
|
|
40
|
+
_selector_covers(selector, candidate_selector)
|
|
41
|
+
for selector in selectors
|
|
42
|
+
for candidate_selector in candidate.include
|
|
43
|
+
):
|
|
44
|
+
found.append(component)
|
|
45
|
+
return found
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_requests(
|
|
49
|
+
repository_identity: str,
|
|
50
|
+
candidates: Iterable[CandidateLike],
|
|
51
|
+
declared_components: list[dict[str, object]],
|
|
52
|
+
) -> tuple[ClarificationRequest, ...]:
|
|
53
|
+
requests: list[ClarificationRequest] = []
|
|
54
|
+
for candidate in candidates:
|
|
55
|
+
covering = _covering_components(candidate, declared_components)
|
|
56
|
+
target_component_id = candidate.id
|
|
57
|
+
if len(covering) == 1:
|
|
58
|
+
declared = covering[0]
|
|
59
|
+
declared_id = str(declared.get("id", "")).strip()
|
|
60
|
+
if declared_id:
|
|
61
|
+
target_component_id = declared_id
|
|
62
|
+
missing_required = tuple(
|
|
63
|
+
field
|
|
64
|
+
for field in ("classification", "purpose")
|
|
65
|
+
if not str(declared.get(field, "")).strip()
|
|
66
|
+
)
|
|
67
|
+
if not missing_required:
|
|
68
|
+
continue
|
|
69
|
+
missing_fields = missing_required
|
|
70
|
+
else:
|
|
71
|
+
missing_fields = FIELD_ORDER
|
|
72
|
+
reasons = tuple(REASON_BY_FIELD[field] for field in missing_fields)
|
|
73
|
+
selector_identity = ",".join(sorted(_normalize_selector(item) for item in candidate.include))
|
|
74
|
+
digest = hashlib.sha256(
|
|
75
|
+
(
|
|
76
|
+
repository_identity
|
|
77
|
+
+ "\0"
|
|
78
|
+
+ candidate.id
|
|
79
|
+
+ "\0"
|
|
80
|
+
+ target_component_id
|
|
81
|
+
+ "\0"
|
|
82
|
+
+ selector_identity
|
|
83
|
+
+ "\0"
|
|
84
|
+
+ ",".join(missing_fields)
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
).hexdigest()[:16]
|
|
87
|
+
requests.append(
|
|
88
|
+
ClarificationRequest(
|
|
89
|
+
id=f"clr-{digest}",
|
|
90
|
+
component_id=target_component_id,
|
|
91
|
+
include=tuple(candidate.include),
|
|
92
|
+
anchors=tuple(candidate.anchors),
|
|
93
|
+
evidence_ids=tuple(candidate.evidence_ids),
|
|
94
|
+
missing_fields=tuple(missing_fields),
|
|
95
|
+
reason_codes=reasons,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return tuple(requests)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import locale
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
SUPPORTED_LANGUAGES = ("en", "ko")
|
|
7
|
+
|
|
8
|
+
_TEXT = {
|
|
9
|
+
"en": {
|
|
10
|
+
"title": "[PTSIP clarification] Architecture decision for {component_id}",
|
|
11
|
+
"intro": "PTSIP stopped before speculative classification because the repository does not declare enough architectural intent for this component.",
|
|
12
|
+
"no_llm": "This clarification request is generated from fixed rules and templates. PTSIP does not call an LLM to create it.",
|
|
13
|
+
"classification": "Which PTSIP architectural classification does this component belong to?",
|
|
14
|
+
"purpose": "What is the primary purpose for which this component was created?",
|
|
15
|
+
"shipped": "Is this component distributed or shipped with the product?",
|
|
16
|
+
"runtime_required": "Is this component required by the product at runtime?",
|
|
17
|
+
"lifecycle_owner": "Which lifecycle owns versioning and releases for this component?",
|
|
18
|
+
"executable": "Does this component contain executable implementation rather than only a declarative contract?",
|
|
19
|
+
"reply": "Please reply using the following structured fields. Keep the invariant codes unchanged where applicable.",
|
|
20
|
+
"required": "Clarification required",
|
|
21
|
+
"none": "No clarification is required from the currently detected component candidates.",
|
|
22
|
+
},
|
|
23
|
+
"ko": {
|
|
24
|
+
"title": "[PTSIP 확인 요청] {component_id} 아키텍처 결정",
|
|
25
|
+
"intro": "PTSIP는 이 컴포넌트의 아키텍처 목적을 선언된 저장소 정보만으로 확인할 수 없어 추측성 분류를 중단했습니다.",
|
|
26
|
+
"no_llm": "이 확인 요청은 고정된 규칙과 템플릿으로 생성됩니다. PTSIP는 질문을 만들기 위해 LLM을 호출하지 않습니다.",
|
|
27
|
+
"classification": "이 컴포넌트의 PTSIP 아키텍처 분류는 무엇입니까?",
|
|
28
|
+
"purpose": "이 컴포넌트를 만든 주된 목적은 무엇입니까?",
|
|
29
|
+
"shipped": "이 컴포넌트는 제품과 함께 배포되거나 제품 패키지에 포함됩니까?",
|
|
30
|
+
"runtime_required": "제품 런타임에서 이 컴포넌트가 필요합니까?",
|
|
31
|
+
"lifecycle_owner": "이 컴포넌트의 버전 관리와 릴리스 생명주기는 누가 소유합니까?",
|
|
32
|
+
"executable": "이 컴포넌트는 선언형 계약만이 아니라 실행 가능한 구현을 포함합니까?",
|
|
33
|
+
"reply": "다음 구조화된 필드로 답변해 주세요. 해당되는 경우 영문 고정 코드는 변경하지 않는 것이 좋습니다.",
|
|
34
|
+
"required": "확인 필요",
|
|
35
|
+
"none": "현재 탐지된 컴포넌트 후보에는 추가 확인이 필요하지 않습니다.",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_OPTIONS = {
|
|
40
|
+
"classification": (
|
|
41
|
+
("PRODUCT", {"en": "Product-owned component", "ko": "Product 소유 컴포넌트"}),
|
|
42
|
+
("TOOLCHAIN", {"en": "Development Toolchain component", "ko": "개발 Toolchain 컴포넌트"}),
|
|
43
|
+
("NEUTRAL_CONTRACT", {"en": "Neutral non-executable contract", "ko": "중립 비실행 계약"}),
|
|
44
|
+
),
|
|
45
|
+
"shipped": (
|
|
46
|
+
("YES", {"en": "Yes", "ko": "예"}),
|
|
47
|
+
("NO", {"en": "No", "ko": "아니요"}),
|
|
48
|
+
),
|
|
49
|
+
"runtime_required": (
|
|
50
|
+
("YES", {"en": "Yes", "ko": "예"}),
|
|
51
|
+
("NO", {"en": "No", "ko": "아니요"}),
|
|
52
|
+
),
|
|
53
|
+
"lifecycle_owner": (
|
|
54
|
+
("PRODUCT", {"en": "Product lifecycle", "ko": "Product 생명주기"}),
|
|
55
|
+
("DEVELOPMENT_TOOLING", {"en": "Development tooling lifecycle", "ko": "개발 Tooling 생명주기"}),
|
|
56
|
+
("INDEPENDENT", {"en": "Independently governed", "ko": "독립적으로 관리"}),
|
|
57
|
+
),
|
|
58
|
+
"executable": (
|
|
59
|
+
("YES", {"en": "Executable implementation is present", "ko": "실행 가능한 구현이 있음"}),
|
|
60
|
+
("NO", {"en": "Declarative/non-executable only", "ko": "선언형/비실행 계약만 있음"}),
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalize(value: str | None) -> str | None:
|
|
66
|
+
if not value:
|
|
67
|
+
return None
|
|
68
|
+
lowered = value.strip().replace("_", "-").casefold()
|
|
69
|
+
if lowered == "ko" or lowered.startswith("ko-"):
|
|
70
|
+
return "ko"
|
|
71
|
+
if lowered == "en" or lowered.startswith("en-"):
|
|
72
|
+
return "en"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_language(explicit: str | None = None) -> str:
|
|
77
|
+
if explicit is not None:
|
|
78
|
+
normalized = _normalize(explicit)
|
|
79
|
+
if normalized is None:
|
|
80
|
+
raise ValueError("Supported languages are: en, ko")
|
|
81
|
+
return normalized
|
|
82
|
+
configured = _normalize(os.environ.get("PTSIP_LANG"))
|
|
83
|
+
if configured:
|
|
84
|
+
return configured
|
|
85
|
+
try:
|
|
86
|
+
system_locale = locale.getlocale()[0]
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
system_locale = None
|
|
89
|
+
return _normalize(system_locale) or "en"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def text(language: str, key: str, **values: str) -> str:
|
|
93
|
+
return _TEXT[language][key].format(**values)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def question(field: str, language: str) -> dict[str, object]:
|
|
97
|
+
payload: dict[str, object] = {
|
|
98
|
+
"field": field,
|
|
99
|
+
"type": "free_text" if field == "purpose" else "single_select",
|
|
100
|
+
"prompt": text(language, field),
|
|
101
|
+
}
|
|
102
|
+
if field in _OPTIONS:
|
|
103
|
+
payload["options"] = [
|
|
104
|
+
{"value": code, "label": labels[language]} for code, labels in _OPTIONS[field]
|
|
105
|
+
]
|
|
106
|
+
return payload
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
|
|
5
|
+
from ..model import DecisionStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
FIELD_ORDER = ("classification", "purpose", "shipped", "runtime_required", "lifecycle_owner", "executable")
|
|
9
|
+
REASON_BY_FIELD = {
|
|
10
|
+
"classification": "MISSING_CLASSIFICATION",
|
|
11
|
+
"purpose": "MISSING_PURPOSE",
|
|
12
|
+
"shipped": "MISSING_PACKAGING_RESPONSIBILITY",
|
|
13
|
+
"runtime_required": "MISSING_RUNTIME_ROLE",
|
|
14
|
+
"lifecycle_owner": "MISSING_LIFECYCLE_OWNER",
|
|
15
|
+
"executable": "MISSING_EXECUTABLE_ROLE",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ClarificationRequest:
|
|
21
|
+
id: str
|
|
22
|
+
component_id: str
|
|
23
|
+
include: tuple[str, ...]
|
|
24
|
+
anchors: tuple[str, ...]
|
|
25
|
+
evidence_ids: tuple[str, ...]
|
|
26
|
+
missing_fields: tuple[str, ...]
|
|
27
|
+
reason_codes: tuple[str, ...]
|
|
28
|
+
status: DecisionStatus = DecisionStatus.INCOMPLETE
|
|
29
|
+
|
|
30
|
+
def as_dict(self) -> dict[str, object]:
|
|
31
|
+
payload = asdict(self)
|
|
32
|
+
payload["status"] = self.status.value
|
|
33
|
+
return payload
|