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
ptsip/cli.py ADDED
@@ -0,0 +1,402 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from .app.client import ControlPlaneClient
8
+ from .clarification.generator import analyze_clarifications
9
+ from .clarification.i18n import resolve_language
10
+ from .clarification.render import render_console, render_issue
11
+ from .clarification.resolution import (
12
+ DecisionAnswer,
13
+ prepare_local_profile,
14
+ validate_answer,
15
+ write_prepared_local_profile,
16
+ )
17
+ from .clarification.transports.github_issue import publish as publish_github_issues
18
+ from .conformance_engine import evaluate_conformance
19
+ from .constants import TOOL_VERSION
20
+ from .doctor import doctor
21
+ from .inspection.components import discover_component_candidates
22
+ from .inspection.dependencies_030 import scan_dependency_edges
23
+ from .inspection.inventory import collect_inventory
24
+ from .pilot.runner import run_pilot
25
+ from .repository.discover import discover_repository
26
+ from .repository.snapshot import capture_snapshot, compare_snapshots
27
+ from .spec_identity import current_spec_identity
28
+ from .validation.profile import validate_profile
29
+
30
+
31
+ def _configure_console_encoding() -> None:
32
+ for stream in (sys.stdout, sys.stderr):
33
+ reconfigure = getattr(stream, "reconfigure", None)
34
+ if callable(reconfigure):
35
+ reconfigure(errors="backslashreplace")
36
+
37
+
38
+ def _emit(payload: object, as_json: bool) -> None:
39
+ if as_json:
40
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
41
+ return
42
+ if isinstance(payload, dict):
43
+ for key, value in payload.items():
44
+ print(f"{key}: {value}")
45
+ else:
46
+ print(payload)
47
+
48
+
49
+ def _yes_no(value: str) -> bool:
50
+ return value.casefold() == "yes"
51
+
52
+
53
+ def _parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(prog="ptsip", description="Reference development tooling for PTSIP")
55
+ parser.add_argument("--version", action="version", version=f"PTSIP Tool {TOOL_VERSION}")
56
+ sub = parser.add_subparsers(dest="command", required=True)
57
+
58
+ p_spec = sub.add_parser("spec", help="Show the canonical specification binding embedded in this tool")
59
+ p_spec.add_argument("--json", action="store_true")
60
+
61
+ p_doctor = sub.add_parser("doctor", help="Check the local environment without modifying the target repository")
62
+ p_doctor.add_argument("path", nargs="?", default=".")
63
+ p_doctor.add_argument("--json", action="store_true")
64
+
65
+ p_inspect = sub.add_parser("inspect", help="Read-only repository evidence collection")
66
+ p_inspect.add_argument("path", nargs="?", default=".")
67
+ p_inspect.add_argument("--json", action="store_true")
68
+
69
+ p_pilot = sub.add_parser("pilot", help="Run a read-only PTSIP pilot and store the report outside the repository by default")
70
+ p_pilot.add_argument("path", nargs="?", default=".")
71
+ p_pilot.add_argument("--report", help="Explicit report destination; may be inside the repository only when the user chooses it")
72
+ p_pilot.add_argument("--json", action="store_true")
73
+
74
+ p_validate = sub.add_parser("validate", help="Validate an existing PTSIP project profile")
75
+ p_validate.add_argument("path", nargs="?", default=".")
76
+ p_validate.add_argument("--profile", help="Explicit project-profile path")
77
+ p_validate.add_argument("--json", action="store_true")
78
+
79
+ p_conform = sub.add_parser(
80
+ "conform",
81
+ help="Evaluate Enforced PTSIP conformance from declared architecture and observed evidence",
82
+ )
83
+ p_conform.add_argument("path", nargs="?", default=".")
84
+ p_conform.add_argument("--profile", help="Explicit project-profile path")
85
+ p_conform.add_argument(
86
+ "--artifact-evidence",
87
+ action="append",
88
+ help="Explicit ptsip-artifact-evidence/v1 JSON/YAML input; repeatable and read-only",
89
+ )
90
+ p_conform.add_argument(
91
+ "--agent-decision",
92
+ action="append",
93
+ help="Explicit ptsip-agent-classification decision input; repeatable review evidence that never overrides the profile",
94
+ )
95
+ p_conform.add_argument(
96
+ "--external-evidence",
97
+ action="append",
98
+ help="Explicit ptsip-external-evidence/v1 JSON/YAML dependency evidence; repeatable and subject/revision checked",
99
+ )
100
+ p_conform.add_argument("--json", action="store_true")
101
+
102
+ p_clarify = sub.add_parser(
103
+ "clarify",
104
+ help="Generate deterministic human clarification requests instead of speculatively inferring missing architectural intent",
105
+ )
106
+ p_clarify.add_argument("path", nargs="?", default=".")
107
+ p_clarify.add_argument("--json", action="store_true")
108
+ p_clarify.add_argument("--lang", choices=("en", "ko"), help="Question language; otherwise PTSIP_LANG, OS locale, then English")
109
+ p_clarify.add_argument("--component", action="append", help="Limit clarification to a detected component candidate ID; repeatable")
110
+ p_clarify.add_argument("--publish", choices=("github-issue",), help="Manual/offline fallback: explicitly publish clarification requests")
111
+ p_clarify.add_argument("--repo", help="Override the detected GitHub origin using owner/repository; requires --publish github-issue")
112
+
113
+ p_gate = sub.add_parser(
114
+ "gate",
115
+ help="Poll/register architecture decisions only when an active coding-agent task requires them",
116
+ )
117
+ p_gate.add_argument("path", nargs="?", default=".")
118
+ p_gate.add_argument("--component", action="append", help="Limit the gate to a detected component candidate ID; repeatable")
119
+ p_gate.add_argument("--lang", choices=("en", "ko"), help="Issue language; otherwise PTSIP_LANG, OS locale, then English")
120
+ p_gate.add_argument("--control-plane", help="PTSIP control-plane base URL; otherwise PTSIP_CONTROL_PLANE_URL")
121
+ p_gate.add_argument("--json", action="store_true")
122
+
123
+ p_resolve = sub.add_parser(
124
+ "resolve",
125
+ help="Explicitly resolve a pending PTSIP decision from an active user/coding-agent session and apply the profile locally",
126
+ )
127
+ p_resolve.add_argument("path", nargs="?", default=".")
128
+ p_resolve.add_argument("--decision", required=True, help="Decision/clarification ID returned by ptsip gate")
129
+ p_resolve.add_argument("--classification", required=True, choices=("PRODUCT", "TOOLCHAIN", "NEUTRAL_CONTRACT"))
130
+ p_resolve.add_argument("--purpose", required=True)
131
+ p_resolve.add_argument("--shipped", required=True, choices=("yes", "no"))
132
+ p_resolve.add_argument("--runtime-required", required=True, choices=("yes", "no"))
133
+ p_resolve.add_argument(
134
+ "--lifecycle-owner",
135
+ required=True,
136
+ choices=("PRODUCT", "DEVELOPMENT_TOOLING", "INDEPENDENT"),
137
+ )
138
+ p_resolve.add_argument("--executable", required=True, choices=("yes", "no"))
139
+ p_resolve.add_argument("--actor", default="coding-agent-session", help="Audit actor label; no free-form inference is performed")
140
+ p_resolve.add_argument("--control-plane", help="PTSIP control-plane base URL; otherwise PTSIP_CONTROL_PLANE_URL")
141
+ p_resolve.add_argument("--json", action="store_true")
142
+ return parser
143
+
144
+
145
+ def main(argv: list[str] | None = None) -> int:
146
+ _configure_console_encoding()
147
+ args = _parser().parse_args(argv)
148
+ try:
149
+ if args.command == "spec":
150
+ _emit(current_spec_identity().as_dict(), args.json)
151
+ return 0
152
+ if args.command == "doctor":
153
+ result = doctor(args.path)
154
+ _emit(result, args.json)
155
+ return 0 if result["python_ok"] and result["target_exists"] else 2
156
+ if args.command == "inspect":
157
+ repo = discover_repository(args.path)
158
+ before = capture_snapshot(repo.root)
159
+ inventory = collect_inventory(repo.root)
160
+ dependencies = scan_dependency_edges(repo.root)
161
+ candidates = discover_component_candidates(repo.root, inventory, dependencies)
162
+ after = capture_snapshot(repo.root)
163
+ comparison = compare_snapshots(before, after)
164
+ payload = {
165
+ "repository": repo.as_dict(),
166
+ "snapshot": {
167
+ "before": before.as_dict(),
168
+ "after": after.as_dict(),
169
+ "comparison": comparison.as_dict(),
170
+ },
171
+ "non_intrusion": {
172
+ "status": "VERIFIED_NO_OBSERVED_CHANGE" if comparison.stable else "CHANGE_OBSERVED_DURING_ANALYSIS",
173
+ "analysis_read_only_by_design": True,
174
+ },
175
+ "inventory": inventory.as_dict(),
176
+ "components": {
177
+ "status": "CANDIDATES_ONLY",
178
+ "candidates": [candidate.as_dict() for candidate in candidates],
179
+ },
180
+ "dependencies": dependencies.as_dict(),
181
+ }
182
+ _emit(payload, args.json)
183
+ return 0 if comparison.stable else 4
184
+ if args.command == "pilot":
185
+ result = run_pilot(args.path, args.report)
186
+ _emit({**result.report, "report_path": str(result.report_path)}, args.json)
187
+ stable = bool(result.report["snapshot"]["comparison"]["stable"])
188
+ return 0 if stable else 4
189
+ if args.command == "validate":
190
+ repo = discover_repository(args.path)
191
+ result = validate_profile(repo.root, args.profile)
192
+ _emit(result.as_dict(), args.json)
193
+ return 0 if result.valid else 3
194
+ if args.command == "conform":
195
+ result = evaluate_conformance(
196
+ args.path,
197
+ args.profile,
198
+ args.artifact_evidence,
199
+ args.agent_decision,
200
+ args.external_evidence,
201
+ )
202
+ _emit(result.report, args.json)
203
+ if result.outcome == "CONFORMANT":
204
+ return 0
205
+ if result.outcome == "NON_CONFORMANT":
206
+ return 5
207
+ return 6
208
+ if args.command == "clarify":
209
+ if args.repo and args.publish != "github-issue":
210
+ raise ValueError("--repo requires --publish github-issue")
211
+ language = resolve_language(args.lang)
212
+ analysis = analyze_clarifications(args.path, args.component)
213
+ payload = analysis.as_dict(language)
214
+ publications = ()
215
+ if args.publish == "github-issue":
216
+ if not analysis.comparison.stable:
217
+ raise RuntimeError("Repository state changed during clarification analysis; refusing to publish questions from invalidated evidence.")
218
+ publications = publish_github_issues(
219
+ repository_root=analysis.repository.root,
220
+ remote=analysis.repository.remote,
221
+ repository_revision=analysis.repository.commit,
222
+ requests=analysis.requests,
223
+ language=language,
224
+ repo_override=args.repo,
225
+ )
226
+ payload["publication"] = {
227
+ "transport": "github-issue",
228
+ "results": [item.as_dict() for item in publications],
229
+ }
230
+ if args.json:
231
+ _emit(payload, True)
232
+ else:
233
+ print(render_console(analysis.requests, language))
234
+ for item in publications:
235
+ print(f"github_issue[{item.status}]: {item.issue_url}")
236
+ return 0 if analysis.comparison.stable else 4
237
+ if args.command == "gate":
238
+ language = resolve_language(args.lang)
239
+ analysis = analyze_clarifications(args.path, args.component)
240
+ if not analysis.comparison.stable:
241
+ raise RuntimeError("Repository state changed during decision-gate analysis; retry against a stable snapshot.")
242
+ repo = analysis.repository
243
+ if not analysis.requests:
244
+ payload = {"status": "NO_DECISION_REQUIRED", "repository": repo.as_dict(), "decisions": []}
245
+ _emit(payload, args.json)
246
+ return 0
247
+ if not repo.remote or repo.remote.provider != "github" or not repo.remote.repository:
248
+ raise RuntimeError("ptsip gate requires a GitHub origin for the decision control plane")
249
+ if not repo.commit or not repo.branch:
250
+ raise RuntimeError("ptsip gate requires a checked-out Git branch and commit")
251
+ client = ControlPlaneClient(args.control_plane)
252
+ decisions: list[dict[str, object]] = []
253
+ blocked = False
254
+ errored = False
255
+ for request in analysis.requests:
256
+ title, body = render_issue(request, language, repo.commit)
257
+ response = client.gate(
258
+ {
259
+ "id": request.id,
260
+ "repository": repo.remote.repository,
261
+ "branch": repo.branch,
262
+ "subject_revision": repo.commit,
263
+ "component_id": request.component_id,
264
+ "request": request.as_dict(),
265
+ "issue": {"title": title, "body": body},
266
+ }
267
+ )
268
+ decisions.append(response)
269
+ status = str(response.get("status", ""))
270
+ if status == "DECISION_REQUIRED":
271
+ blocked = True
272
+ elif status in {"STALE", "CONFLICT", "INVALID", "RESOLVED_APPLICATION_REQUIRED"}:
273
+ errored = True
274
+ status = "DECISION_REQUIRED" if blocked else ("DECISION_ERROR" if errored else "RESOLVED")
275
+ _emit({"status": status, "repository": repo.as_dict(), "decisions": decisions}, args.json)
276
+ if blocked:
277
+ return 7
278
+ if errored:
279
+ return 8
280
+ return 0
281
+ if args.command == "resolve":
282
+ repo = discover_repository(args.path)
283
+ if not repo.commit or not repo.branch:
284
+ raise RuntimeError("ptsip resolve requires a checked-out Git branch and commit")
285
+ if not repo.remote or repo.remote.provider != "github" or not repo.remote.repository:
286
+ raise RuntimeError("ptsip resolve requires a GitHub origin matching the decision control plane")
287
+ answer = DecisionAnswer(
288
+ classification=args.classification,
289
+ purpose=args.purpose.strip(),
290
+ shipped=_yes_no(args.shipped),
291
+ runtime_required=_yes_no(args.runtime_required),
292
+ lifecycle_owner=args.lifecycle_owner,
293
+ executable=_yes_no(args.executable),
294
+ )
295
+ validation = validate_answer(answer)
296
+ if not validation.valid:
297
+ _emit({"status": "CONFLICT", "validation": validation.as_dict()}, args.json)
298
+ return 8
299
+
300
+ client = ControlPlaneClient(args.control_plane)
301
+ lookup = client.decision({"decision_id": args.decision})
302
+ decision = lookup.get("decision")
303
+ if not isinstance(decision, dict):
304
+ raise RuntimeError("Control plane returned no decision record")
305
+ if str(decision.get("repository", "")) != repo.remote.repository:
306
+ raise RuntimeError("Decision repository does not match the local GitHub origin")
307
+ if str(decision.get("branch", "")) != repo.branch:
308
+ raise RuntimeError("Decision branch does not match the checked-out local branch; run ptsip gate first")
309
+ if str(decision.get("subject_revision", "")) != repo.commit:
310
+ _emit(
311
+ {
312
+ "status": "STALE_REQUIRES_GATE",
313
+ "message": "Decision target revision differs from the active repository. Run ptsip gate for the affected component before retrying the same authoritative decision.",
314
+ "decision": decision,
315
+ },
316
+ args.json,
317
+ )
318
+ return 8
319
+
320
+ existing_status = str(decision.get("status", ""))
321
+ stored_answer = decision.get("answer")
322
+ application_status = str(decision.get("application_status", ""))
323
+ if existing_status == "RESOLVED":
324
+ if stored_answer != answer.as_dict():
325
+ _emit(
326
+ {
327
+ "status": "ALREADY_RESOLVED",
328
+ "accepted": False,
329
+ "message": "The authoritative decision already exists with different facts; this later answer cannot replace it.",
330
+ "decision": decision,
331
+ },
332
+ args.json,
333
+ )
334
+ return 9
335
+ if application_status in {"APPLIED", "LOCAL_APPLIED"}:
336
+ _emit({"status": "ALREADY_APPLIED", "decision": decision}, args.json)
337
+ return 0
338
+ elif existing_status != "PENDING":
339
+ _emit({"status": existing_status or "DECISION_ERROR", "decision": decision}, args.json)
340
+ return 8
341
+
342
+ request = decision.get("request")
343
+ if not isinstance(request, dict):
344
+ raise RuntimeError("Decision record has no request payload")
345
+ include = request.get("include")
346
+ component_id = str(decision.get("component_id", ""))
347
+ if not isinstance(include, list) or not component_id:
348
+ raise RuntimeError("Decision request has no component include selectors")
349
+
350
+ # Full local profile validation happens before the central CAS. A
351
+ # profile-conflicting answer therefore cannot become the first
352
+ # authoritative resolution merely because validation happened late.
353
+ try:
354
+ prepared = prepare_local_profile(repo.root, component_id, [str(item) for item in include], answer)
355
+ except (ValueError, RuntimeError) as exc:
356
+ _emit({"status": "CONFLICT", "message": str(exc), "decision": decision}, args.json)
357
+ return 8
358
+
359
+ response = client.resolve(
360
+ {
361
+ "decision_id": args.decision,
362
+ "answer": answer.as_dict(),
363
+ "actor": args.actor,
364
+ }
365
+ )
366
+ if response.get("status") != "RESOLVED" or not response.get("accepted"):
367
+ _emit(response, args.json)
368
+ return 9
369
+ resolved = response.get("decision")
370
+ if not isinstance(resolved, dict):
371
+ raise RuntimeError("Control plane returned no resolved decision record")
372
+ if str(resolved.get("subject_revision", "")) != repo.commit:
373
+ client.application({"decision_id": args.decision, "status": "STALE"})
374
+ _emit({"status": "STALE_REQUIRES_GATE", "decision": resolved}, args.json)
375
+ return 8
376
+
377
+ try:
378
+ profile = write_prepared_local_profile(prepared)
379
+ except Exception:
380
+ client.application({"decision_id": args.decision, "status": "FAILED"})
381
+ raise
382
+ application = client.application(
383
+ {
384
+ "decision_id": args.decision,
385
+ "status": "LOCAL_APPLIED",
386
+ "applied_revision": repo.commit,
387
+ }
388
+ )
389
+ _emit(
390
+ {
391
+ "status": "RESOLVED",
392
+ "decision": resolved,
393
+ "profile_path": str(profile),
394
+ "application": application,
395
+ },
396
+ args.json,
397
+ )
398
+ return 0
399
+ except (FileNotFoundError, PermissionError, OSError, RuntimeError, ValueError) as exc:
400
+ print(f"PTSIP error: {exc}", file=sys.stderr)
401
+ return 2
402
+ return 2