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.
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/PKG-INFO +3 -1
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__init__.py +1 -1
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/__init__.py +84 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_cli.py +143 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_output_safety.py +55 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_serialize.py +45 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_terminal.py +70 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_verdict.py +74 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/_version.py +7 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/exit_codes.py +23 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/models.py +301 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/conformance/protocol.py +46 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/__init__.py +47 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/_catalog_fixture.py +80 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/binding.py +64 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/executor.py +173 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/knowledge.py +64 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/plan.py +110 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/request.py +79 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/selector.py +59 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/__init__.py +53 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/foundation_seed.py +207 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/PKG-INFO +3 -1
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/SOURCES.txt +22 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/entry_points.txt +1 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/requires.txt +3 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/pyproject.toml +18 -1
- plm_skill_kernel-1.2.0/tests/test_executor.py +308 -0
- plm_skill_kernel-1.2.0/tests/test_foundation_mocks.py +138 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/README.md +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__main__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/dispatcher.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/registry.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/_registry.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/context.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/decorator.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/types.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/server.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/suggest.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/generate.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/dedupe.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/normalise.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/parse.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/_v1_corpus.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/get_record.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/resolve.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/retrieve.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/search.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/validate.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/__init__.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_search_engine.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_skill_registry.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_tool_definitions.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/skill_orchestrator.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/dependency_links.txt +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/top_level.txt +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/setup.cfg +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_asgi.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_unit.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_dispatcher.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_asgi.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_unit.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_sdk_decorator.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_server.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_skills_cleansing.py +0 -0
- {plm_skill_kernel-1.0.2 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge.py +0 -0
- {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
|
|
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
|
|
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"]
|