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.
Files changed (66) hide show
  1. ptsip/__init__.py +3 -0
  2. ptsip/__main__.py +4 -0
  3. ptsip/app/__init__.py +5 -0
  4. ptsip/app/client.py +53 -0
  5. ptsip/app/github_client.py +200 -0
  6. ptsip/app/server.py +130 -0
  7. ptsip/app/service.py +285 -0
  8. ptsip/app/store.py +233 -0
  9. ptsip/artifact_evidence.py +150 -0
  10. ptsip/build_resolution.py +298 -0
  11. ptsip/clarification/__init__.py +6 -0
  12. ptsip/clarification/generator.py +120 -0
  13. ptsip/clarification/generator_core.py +98 -0
  14. ptsip/clarification/i18n.py +106 -0
  15. ptsip/clarification/model.py +33 -0
  16. ptsip/clarification/render.py +78 -0
  17. ptsip/clarification/resolution/__init__.py +29 -0
  18. ptsip/clarification/resolution/model.py +33 -0
  19. ptsip/clarification/resolution/parser.py +56 -0
  20. ptsip/clarification/resolution/profile_projection.py +198 -0
  21. ptsip/clarification/resolution/resolver.py +30 -0
  22. ptsip/clarification/transports/__init__.py +1 -0
  23. ptsip/clarification/transports/github_issue.py +162 -0
  24. ptsip/cli.py +402 -0
  25. ptsip/conformance.py +531 -0
  26. ptsip/conformance_audit.py +143 -0
  27. ptsip/conformance_engine.py +396 -0
  28. ptsip/constants.py +9 -0
  29. ptsip/doctor.py +33 -0
  30. ptsip/inspection/__init__.py +1 -0
  31. ptsip/inspection/components.py +74 -0
  32. ptsip/inspection/dependencies.py +496 -0
  33. ptsip/inspection/dependencies_030.py +69 -0
  34. ptsip/inspection/dotnet.py +188 -0
  35. ptsip/inspection/go.py +157 -0
  36. ptsip/inspection/inventory.py +161 -0
  37. ptsip/inspection/javascript.py +376 -0
  38. ptsip/inspection/lexing.py +121 -0
  39. ptsip/inspection/source_adapters.py +60 -0
  40. ptsip/lifecycle_evidence.py +245 -0
  41. ptsip/model.py +105 -0
  42. ptsip/pilot/__init__.py +1 -0
  43. ptsip/pilot/runner.py +170 -0
  44. ptsip/repository/__init__.py +1 -0
  45. ptsip/repository/discover.py +68 -0
  46. ptsip/repository/remote.py +54 -0
  47. ptsip/repository/snapshot.py +173 -0
  48. ptsip/review_evidence.py +406 -0
  49. ptsip/spec_identity.py +22 -0
  50. ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
  51. ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
  52. ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
  53. ptsip/specdata/ptsip-profile.schema.json +310 -0
  54. ptsip/specdata/ptsip-registry.yaml +189 -0
  55. ptsip/storage/__init__.py +1 -0
  56. ptsip/storage/local_state.py +32 -0
  57. ptsip/validation/__init__.py +1 -0
  58. ptsip/validation/components.py +164 -0
  59. ptsip/validation/profile.py +172 -0
  60. ptsip/validation/rules.py +163 -0
  61. ptsip-0.3.1.dist-info/METADATA +338 -0
  62. ptsip-0.3.1.dist-info/RECORD +66 -0
  63. ptsip-0.3.1.dist-info/WHEEL +5 -0
  64. ptsip-0.3.1.dist-info/entry_points.txt +3 -0
  65. ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
  66. 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