plm-skill-kernel 1.0.2__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/PKG-INFO +3 -1
  2. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__init__.py +1 -1
  3. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/__init__.py +84 -0
  4. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_cli.py +143 -0
  5. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_output_safety.py +55 -0
  6. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_serialize.py +45 -0
  7. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_terminal.py +70 -0
  8. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_verdict.py +74 -0
  9. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_version.py +7 -0
  10. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/exit_codes.py +23 -0
  11. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/models.py +301 -0
  12. plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/protocol.py +46 -0
  13. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/__init__.py +47 -0
  14. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/_catalog_fixture.py +80 -0
  15. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/binding.py +64 -0
  16. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/executor.py +173 -0
  17. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/knowledge.py +64 -0
  18. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/plan.py +110 -0
  19. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/request.py +79 -0
  20. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/selector.py +59 -0
  21. plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/__init__.py +53 -0
  22. plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/foundation_seed.py +207 -0
  23. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/PKG-INFO +3 -1
  24. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/SOURCES.txt +22 -0
  25. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/entry_points.txt +1 -0
  26. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/requires.txt +3 -0
  27. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/pyproject.toml +18 -1
  28. plm_skill_kernel-1.2.0/tests/test_executor.py +308 -0
  29. plm_skill_kernel-1.2.0/tests/test_foundation_mocks.py +138 -0
  30. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/README.md +0 -0
  31. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__main__.py +0 -0
  32. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/dispatcher.py +0 -0
  33. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/registry.py +0 -0
  34. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/__init__.py +0 -0
  35. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/_registry.py +0 -0
  36. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/context.py +0 -0
  37. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/decorator.py +0 -0
  38. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/types.py +0 -0
  39. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/server.py +0 -0
  40. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/__init__.py +0 -0
  41. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/__init__.py +0 -0
  42. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/suggest.py +0 -0
  43. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/__init__.py +0 -0
  44. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/generate.py +0 -0
  45. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/__init__.py +0 -0
  46. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/dedupe.py +0 -0
  47. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/normalise.py +0 -0
  48. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/__init__.py +0 -0
  49. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/parse.py +0 -0
  50. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/__init__.py +0 -0
  51. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/_v1_corpus.py +0 -0
  52. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/get_record.py +0 -0
  53. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/resolve.py +0 -0
  54. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/retrieve.py +0 -0
  55. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/search.py +0 -0
  56. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/validate.py +0 -0
  57. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/__init__.py +0 -0
  58. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +0 -0
  59. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_search_engine.py +0 -0
  60. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_skill_registry.py +0 -0
  61. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_tool_definitions.py +0 -0
  62. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/skill_orchestrator.py +0 -0
  63. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/dependency_links.txt +0 -0
  64. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/top_level.txt +0 -0
  65. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/setup.cfg +0 -0
  66. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_asgi.py +0 -0
  67. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_unit.py +0 -0
  68. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_dispatcher.py +0 -0
  69. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_asgi.py +0 -0
  70. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_unit.py +0 -0
  71. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_sdk_decorator.py +0 -0
  72. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_server.py +0 -0
  73. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_skills_cleansing.py +0 -0
  74. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge.py +0 -0
  75. {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plm-skill-kernel
3
- Version: 1.0.2
3
+ Version: 1.2.0
4
4
  Summary: TracePulse PLM Skill Kernel — Wave 2 Conv A. Decorator-based Skill SDK + V1 in-process dispatcher + Kernel HTTP server stub matching CR.10 §7.bis (POST /v1/skills/{id}/invoke). Ships 3 starter skills (cleansing.normalise, cleansing.dedupe, bpmn.generate). Sibling of plm-engine-core; the two communicate over the V1.1 HTTP loopback (SKILL_KERNEL_LOOPBACK=on) — no Python-level coupling at the dispatch boundary.
5
5
  Author: TracePulse
6
6
  License: Proprietary
@@ -17,6 +17,8 @@ Provides-Extra: test
17
17
  Requires-Dist: pytest; extra == "test"
18
18
  Requires-Dist: pytest-asyncio; extra == "test"
19
19
  Requires-Dist: httpx<1,>=0.27; extra == "test"
20
+ Provides-Extra: conformance
21
+ Requires-Dist: plm-skill-packages; extra == "conformance"
20
22
  Provides-Extra: docs
21
23
  Requires-Dist: pdoc>=14.0; extra == "docs"
22
24
 
@@ -14,7 +14,7 @@ sanctioned interaction is the V1.1 HTTP loopback (env-flagged on
14
14
  ``SKILL_KERNEL_LOOPBACK``); any Python-level reach-in is forbidden by
15
15
  the import-linter contract in ``pyproject.toml``.
16
16
  """
17
- __version__ = "1.0.2"
17
+ __version__ = "1.2.0"
18
18
 
19
19
  # The decorator + sdk surface is the public authoring API. Re-exported
20
20
  # here so callers write ``from plm_skill_kernel import skill`` rather
@@ -0,0 +1,84 @@
1
+ """Conformance contract layer (FTR-2008 / MV1-DEC-018 Candidate 1).
2
+
3
+ Owns:
4
+ - :class:`ManifestProfileValidator` typing.Protocol (the validator seam)
5
+ - :class:`ConformanceReport` + :class:`ConformanceFinding` (report models)
6
+ - :data:`REPORT_CONTRACT_VERSION` (independent SemVer per MV1-DEC-005)
7
+ - :class:`ExitCode` constants (MV1-DEC-006)
8
+
9
+ Does NOT extend or import the 4 frozen SDK wire models
10
+ (SkillContext, SkillInvokeRequest, SkillInvokeResult, SkillErrorEnvelope).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Dict, List, Type
15
+
16
+ from ._version import REPORT_CONTRACT_VERSION
17
+ from .exit_codes import ExitCode
18
+ from .models import (
19
+ ConformanceFinding,
20
+ ConformanceReport,
21
+ EnumerationEntry,
22
+ EnumerationPayload,
23
+ EnumerationState,
24
+ ExecutionStatusCounts,
25
+ FindingExecutionStatus,
26
+ FindingSeverity,
27
+ ResolvedReference,
28
+ SeverityCounts,
29
+ Verdict,
30
+ )
31
+ from .protocol import ManifestProfileValidator
32
+ from ._verdict import derive_exit_code, expected_exit_code
33
+
34
+ __all__ = [
35
+ "REPORT_CONTRACT_VERSION",
36
+ "ConformanceFinding",
37
+ "ConformanceReport",
38
+ "EnumerationEntry",
39
+ "EnumerationPayload",
40
+ "EnumerationState",
41
+ "ExitCode",
42
+ "ExecutionStatusCounts",
43
+ "FindingExecutionStatus",
44
+ "FindingSeverity",
45
+ "ManifestProfileValidator",
46
+ "ResolvedReference",
47
+ "SeverityCounts",
48
+ "Verdict",
49
+ "available_profiles",
50
+ "derive_exit_code",
51
+ "expected_exit_code",
52
+ "get_validator",
53
+ "register_profile",
54
+ ]
55
+
56
+
57
+ # ── Profile registration seam ────────────────────────────────────────────────
58
+
59
+ _PROFILE_REGISTRY: Dict[str, Type[ManifestProfileValidator]] = {}
60
+
61
+
62
+ def register_profile(
63
+ profile_id: str, validator_cls: Type[ManifestProfileValidator]
64
+ ) -> None:
65
+ """Register a validator class for a profile ID.
66
+
67
+ Called by profile implementations (e.g. in plm-skill-packages) at
68
+ import time to make themselves available to the CLI.
69
+ """
70
+ _PROFILE_REGISTRY[profile_id] = validator_cls
71
+
72
+
73
+ def get_validator(profile_id: str) -> ManifestProfileValidator:
74
+ """Instantiate and return the validator for *profile_id*.
75
+
76
+ Raises :class:`KeyError` if *profile_id* is not registered.
77
+ """
78
+ cls = _PROFILE_REGISTRY[profile_id]
79
+ return cls()
80
+
81
+
82
+ def available_profiles() -> List[str]:
83
+ """Return list of registered profile IDs."""
84
+ return sorted(_PROFILE_REGISTRY.keys())
@@ -0,0 +1,143 @@
1
+ """CLI entry for ``plm-skill-conformance`` — conformance validation runner.
2
+
3
+ Usage::
4
+
5
+ plm-skill-conformance --profile manifest-v1 [--report <path>] [snapshot_root]
6
+
7
+ Exit codes per MV1-DEC-006:
8
+ 0 = VALID
9
+ 1 = INVALID (blocking findings)
10
+ 2 = decision-gated / not-executable / degraded
11
+ 64 = invoker-correctable (usage/config/profile/unsafe-path)
12
+ 70 = unexpected defect
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import List, Optional
20
+
21
+ from ._output_safety import validate_output_path
22
+ from ._serialize import serialize_report
23
+ from ._terminal import render_terminal
24
+ from .exit_codes import ExitCode
25
+ from .models import ConformanceReport
26
+
27
+
28
+ def main(argv: Optional[List[str]] = None) -> None:
29
+ """Console-script entry — ``plm-skill-conformance``.
30
+
31
+ Separate from the server CLI (``plm-skill-kernel``) to satisfy AC10:
32
+ running without --profile on the server CLI remains unchanged.
33
+ """
34
+ parser = argparse.ArgumentParser(
35
+ prog="plm-skill-conformance",
36
+ description="Run manifest conformance validation against a profile.",
37
+ )
38
+ parser.add_argument(
39
+ "--profile",
40
+ required=False,
41
+ default=None,
42
+ help="Profile to validate against (e.g. 'manifest-v1'). Required.",
43
+ )
44
+ parser.add_argument(
45
+ "--report",
46
+ type=str,
47
+ default=None,
48
+ help=(
49
+ "Output path for the JSON report. "
50
+ "Default: <snapshot_root>/.plm/skill-conformance/report.json"
51
+ ),
52
+ )
53
+ parser.add_argument(
54
+ "snapshot_root",
55
+ nargs="?",
56
+ default=".",
57
+ help="Path to the package snapshot root (default: current directory).",
58
+ )
59
+
60
+ args = parser.parse_args(argv)
61
+
62
+ # MV1-DEC-006: missing --profile is invoker-correctable (exit 64)
63
+ if args.profile is None:
64
+ print("ERROR: --profile is required.", file=sys.stderr)
65
+ sys.exit(ExitCode.INVOKER_CORRECTABLE)
66
+
67
+ # Resolve snapshot root
68
+ snapshot_root = Path(args.snapshot_root).resolve()
69
+
70
+ # Determine report output path
71
+ if args.report:
72
+ report_path = Path(args.report).resolve()
73
+ else:
74
+ report_path = snapshot_root / ".plm" / "skill-conformance" / "report.json"
75
+
76
+ # Output-path safety check (AC7, AC8, AC14) — BEFORE any validation
77
+ safety_error = validate_output_path(report_path, snapshot_root)
78
+ if safety_error:
79
+ print(f"ERROR: {safety_error}", file=sys.stderr)
80
+ sys.exit(ExitCode.INVOKER_CORRECTABLE)
81
+
82
+ # Load the profile validator via registration seam
83
+ try:
84
+ from . import available_profiles, get_validator
85
+
86
+ validator = get_validator(args.profile)
87
+ except KeyError:
88
+ from . import available_profiles
89
+
90
+ print(
91
+ f"ERROR: Unknown profile '{args.profile}'. "
92
+ f"Available: {available_profiles()}",
93
+ file=sys.stderr,
94
+ )
95
+ sys.exit(ExitCode.INVOKER_CORRECTABLE)
96
+ except ImportError as exc:
97
+ print(
98
+ f"ERROR: Failed to load profile validator: {exc}. "
99
+ f"Install plm-skill-packages or the appropriate validator package.",
100
+ file=sys.stderr,
101
+ )
102
+ sys.exit(ExitCode.INVOKER_CORRECTABLE)
103
+ except Exception as exc:
104
+ print(
105
+ f"ERROR: Failed to instantiate profile validator: {exc}",
106
+ file=sys.stderr,
107
+ )
108
+ sys.exit(ExitCode.INVOKER_CORRECTABLE)
109
+
110
+ # Run validation — fail-closed: exceptions → exit 70
111
+ try:
112
+ report = validator.validate(str(snapshot_root))
113
+ except Exception as exc:
114
+ print(
115
+ f"DEFECT: Validator raised unexpected error: {type(exc).__name__}: {exc}",
116
+ file=sys.stderr,
117
+ )
118
+ sys.exit(ExitCode.DEFECT)
119
+
120
+ # Canonicalize for byte-identity (AC8/AC13)
121
+ canonical_report = ConformanceReport.canonicalize(report)
122
+
123
+ # Write JSON report — fail-closed: exceptions → exit 70
124
+ try:
125
+ report_path.parent.mkdir(parents=True, exist_ok=True)
126
+ report_path.write_text(
127
+ serialize_report(canonical_report), encoding="utf-8"
128
+ )
129
+ except Exception as exc:
130
+ print(
131
+ f"DEFECT: Failed to write report to '{report_path}': {exc}",
132
+ file=sys.stderr,
133
+ )
134
+ sys.exit(ExitCode.DEFECT)
135
+
136
+ # Terminal projection
137
+ render_terminal(canonical_report)
138
+
139
+ # Exit with the guaranteed-consistent exit code from the report
140
+ sys.exit(canonical_report.exit_code)
141
+
142
+
143
+ __all__ = ["main"]
@@ -0,0 +1,55 @@
1
+ """Output-path safety validation (AC7/AC8/AC14).
2
+
3
+ Rules:
4
+ - Report path OUTSIDE snapshot root → always safe.
5
+ - Report path INSIDE snapshot root under a pre-excluded directory → safe.
6
+ - Report path INSIDE snapshot root, NOT under a pre-excluded directory →
7
+ FAIL before writing (exit 64, invoker-correctable).
8
+
9
+ The default report destination is ``<snapshot_root>/.plm/skill-conformance/report.json``
10
+ which is inside the snapshot but under ``.plm/`` (pre-excluded), so it is safe.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ # Directories that are pre-excluded from package enumeration — safe to write into.
18
+ PRE_EXCLUDED_DIRS = frozenset({
19
+ ".plm", ".git", ".federation", "__pycache__", "node_modules",
20
+ ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
21
+ })
22
+
23
+
24
+ def validate_output_path(report_path: Path, snapshot_root: Path) -> Optional[str]:
25
+ """Return an error message if *report_path* is unsafe; ``None`` if OK.
26
+
27
+ An unsafe path is one that is inside the snapshot root but not under
28
+ a pre-excluded directory — writing there would contaminate the
29
+ authoring snapshot and potentially alter enumeration results.
30
+ """
31
+ # Resolve both to absolute for reliable comparison
32
+ report_abs = report_path.resolve()
33
+ snapshot_abs = snapshot_root.resolve()
34
+
35
+ # Outside snapshot → always safe
36
+ try:
37
+ relative = report_abs.relative_to(snapshot_abs)
38
+ except ValueError:
39
+ return None
40
+
41
+ # Inside snapshot — check if first path component is pre-excluded
42
+ parts = relative.parts
43
+ if parts and parts[0] in PRE_EXCLUDED_DIRS:
44
+ return None
45
+
46
+ return (
47
+ f"Report path '{report_abs}' is inside the snapshot root "
48
+ f"'{snapshot_abs}' and not under a pre-excluded directory "
49
+ f"({sorted(PRE_EXCLUDED_DIRS)}). Writing here would contaminate "
50
+ f"the snapshot. Use --report with a path outside the snapshot "
51
+ f"or under .plm/."
52
+ )
53
+
54
+
55
+ __all__ = ["PRE_EXCLUDED_DIRS", "validate_output_path"]
@@ -0,0 +1,45 @@
1
+ """Canonical report serialization (AC8/AC13 byte-identity guarantee).
2
+
3
+ This module is the SINGLE serialization boundary for conformance reports.
4
+ All code that produces report JSON bytes MUST use :func:`serialize_report`
5
+ rather than calling ``model_dump_json()`` directly.
6
+
7
+ Key guarantee: ``ensure_ascii=True`` ensures non-ASCII characters are
8
+ escaped as ``\\uXXXX``, making the output byte-identical across platforms
9
+ regardless of locale or filesystem encoding.
10
+
11
+ Added in REPORT_CONTRACT_VERSION 1.1.0 (MV1-DEC-005 SemVer-minor).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+
17
+ from .models import ConformanceReport
18
+
19
+
20
+ def serialize_report(report: ConformanceReport) -> str:
21
+ """Produce canonical JSON for a conformance report.
22
+
23
+ Round-trips through Pydantic's serializer (for type coercion and
24
+ field-order stability) then re-serializes with ``ensure_ascii=True``
25
+ for cross-platform byte-identity.
26
+
27
+ Parameters
28
+ ----------
29
+ report : ConformanceReport
30
+ Should already be canonicalized via ``ConformanceReport.canonicalize()``.
31
+
32
+ Returns
33
+ -------
34
+ str
35
+ Canonical JSON string (indent=2, ensure_ascii=True, no trailing newline).
36
+ """
37
+ return json.dumps(
38
+ json.loads(report.model_dump_json()),
39
+ indent=2,
40
+ ensure_ascii=True,
41
+ sort_keys=False,
42
+ )
43
+
44
+
45
+ __all__ = ["serialize_report"]
@@ -0,0 +1,70 @@
1
+ """Deterministic terminal projection (MV1-DEC-005).
2
+
3
+ Order: verdict line → profile/version → report contract version → counts →
4
+ findings sorted by (severity DESC [error>warning>info], rule_code ASC, path ASC).
5
+
6
+ Same report → identical terminal output.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import IO
12
+
13
+ from .models import (
14
+ ConformanceFinding,
15
+ ConformanceReport,
16
+ FindingSeverity,
17
+ )
18
+
19
+
20
+ _SEVERITY_ORDER = {
21
+ FindingSeverity.ERROR: 0,
22
+ FindingSeverity.WARNING: 1,
23
+ FindingSeverity.INFO: 2,
24
+ }
25
+
26
+
27
+ def render_terminal(report: ConformanceReport, *, stream: IO[str] = sys.stdout) -> None:
28
+ """Render the conformance report to terminal in deterministic order.
29
+
30
+ Parameters
31
+ ----------
32
+ report : ConformanceReport
33
+ The canonicalized report to render.
34
+ stream : IO[str]
35
+ Output stream (default: stdout). Overridable for testing.
36
+ """
37
+ # Verdict line
38
+ stream.write(f"\nVerdict: {report.verdict.value}\n")
39
+ stream.write(f"Profile: {report.profile_id} ({report.profile_version})\n")
40
+ stream.write(f"Report contract: {report.report_contract_version}\n")
41
+
42
+ # Counts
43
+ c = report.counts_by_severity
44
+ stream.write(
45
+ f"\nFindings: {c.error} error, {c.warning} warning, {c.info} info\n"
46
+ )
47
+
48
+ # Deterministically ordered findings
49
+ if report.findings:
50
+ ordered = sorted(
51
+ report.findings,
52
+ key=lambda f: (
53
+ _SEVERITY_ORDER.get(f.severity, 99),
54
+ f.rule_code,
55
+ f.path or "",
56
+ ),
57
+ )
58
+ stream.write("\n--- Findings ---\n")
59
+ for f in ordered:
60
+ path_str = f" @ {f.path}" if f.path else ""
61
+ decision_str = f" [decision: {f.decision_id}]" if f.decision_id else ""
62
+ stream.write(
63
+ f" [{f.severity.value.upper()}] "
64
+ f"{f.rule_code}{path_str}: {f.message}{decision_str}\n"
65
+ )
66
+
67
+ stream.write("\n")
68
+
69
+
70
+ __all__ = ["render_terminal"]
@@ -0,0 +1,74 @@
1
+ """Verdict-to-exit-code derivation (MV1-DEC-006).
2
+
3
+ Deterministic verdict precedence (highest → lowest):
4
+
5
+ 1. INVALID (exit 1) — any finding with severity=error + execution_status=invalid
6
+ 2. decision-gated (exit 2) — any finding with execution_status=blocked_by_decision
7
+ 3. required-not-executable (exit 2) — execution_status=not_executable
8
+ 4. compatibility-not-executable (exit 2) — execution_status=compatibility_not_executable
9
+ 5. degraded-enumeration (exit 2) — enumeration.state == DEGRADED
10
+ 6. VALID (exit 0) — none of the above
11
+
12
+ When INVALID and gated/not-executable co-exist, INVALID wins (exit 1 > exit 2).
13
+ Rationale: blocking conformance failures are author-actionable NOW (the author
14
+ can fix the schema error); gated states depend on external resolution (a decision
15
+ owner must close the decision, or a dependency must be published). Reporting
16
+ INVALID is the conservative/safe outcome — it surfaces the actionable fix first.
17
+
18
+ The precedence is enforced by the *validator* when it produces the verdict.
19
+ The kernel-side ``derive_exit_code`` simply maps verdict → exit code and
20
+ returns 70 (defect) for any unknown/future verdict value (fail-closed).
21
+
22
+ PENDING RATIFICATION (FTR-2034 decision register)
23
+ ──────────────────────────────────────────────────
24
+ This verdict-precedence rule (INVALID > gated when both coexist) is a proposed
25
+ kernel-side consistency rule. It is NOT yet a ratified MV1-DEC-* decision.
26
+ It is locked by regression tests in tests/conformance/test_verdict_derivation.py
27
+ (test_invalid_wins_over_gated, test_precedence_invalid_over_not_executable).
28
+ Any change to this ordering requires explicit owner ratification via FTR-2034.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import TYPE_CHECKING
35
+
36
+ from .exit_codes import ExitCode
37
+ from .models import Verdict
38
+
39
+ if TYPE_CHECKING:
40
+ from .models import ConformanceReport
41
+
42
+
43
+ _VERDICT_TO_EXIT: dict[Verdict, int] = {
44
+ Verdict.VALID: ExitCode.VALID,
45
+ Verdict.INVALID: ExitCode.INVALID,
46
+ Verdict.DECISION_GATED: ExitCode.GATED,
47
+ Verdict.REQUIRED_NOT_EXECUTABLE: ExitCode.GATED,
48
+ Verdict.COMPATIBILITY_NOT_EXECUTABLE: ExitCode.GATED,
49
+ Verdict.DEGRADED_ENUMERATION: ExitCode.GATED,
50
+ }
51
+
52
+
53
+ def expected_exit_code(verdict: Verdict) -> int:
54
+ """Single authority for verdict → exit-code mapping (MV1-DEC-006).
55
+
56
+ Pure function: takes only a Verdict enum, never reads report.exit_code.
57
+ Used by the model validator to enforce consistency and by all report
58
+ producers to compute the correct exit_code before construction.
59
+
60
+ Fail-closed: unknown verdict value returns 70 (defect).
61
+ """
62
+ return _VERDICT_TO_EXIT.get(verdict, ExitCode.DEFECT)
63
+
64
+
65
+ def derive_exit_code(report: "ConformanceReport") -> int:
66
+ """Map report verdict to exit code — thin wrapper over expected_exit_code.
67
+
68
+ Kept for backward compatibility. Delegates entirely to
69
+ :func:`expected_exit_code`.
70
+ """
71
+ return expected_exit_code(report.verdict)
72
+
73
+
74
+ __all__ = ["derive_exit_code", "expected_exit_code"]
@@ -0,0 +1,7 @@
1
+ """Report contract version — independently versioned per MV1-DEC-005.
2
+
3
+ SemVer rules:
4
+ - Minor bump: add optional field to ConformanceReport or ConformanceFinding.
5
+ - Major bump: remove/rename/require a field, or change verdict semantics.
6
+ """
7
+ REPORT_CONTRACT_VERSION = "1.1.0"
@@ -0,0 +1,23 @@
1
+ """Exit-code constants per MV1-DEC-006.
2
+
3
+ Mapping:
4
+ 0 — VALID (conformant)
5
+ 1 — INVALID (blocking conformance findings, severity=error)
6
+ 2 — decision-gated / required-not-executable /
7
+ compatibility-not-executable / degraded-enumeration
8
+ 64 — invoker-correctable (usage, config, profile selection, unsafe path)
9
+ 70 — unexpected defect (validator crash, serialization failure, write error)
10
+ """
11
+
12
+
13
+ class ExitCode:
14
+ """MV1-DEC-006 exit codes for conformance validation."""
15
+
16
+ VALID = 0
17
+ INVALID = 1
18
+ GATED = 2
19
+ INVOKER_CORRECTABLE = 64
20
+ DEFECT = 70
21
+
22
+
23
+ __all__ = ["ExitCode"]