runmark 0.2.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.
- runmark/__init__.py +7 -0
- runmark/__main__.py +8 -0
- runmark/cli/__init__.py +5 -0
- runmark/cli/app.py +74 -0
- runmark/cli/commands/__init__.py +27 -0
- runmark/cli/commands/check.py +54 -0
- runmark/cli/commands/contract.py +236 -0
- runmark/cli/commands/diff.py +90 -0
- runmark/cli/commands/doctor.py +73 -0
- runmark/cli/commands/history.py +25 -0
- runmark/cli/commands/init.py +65 -0
- runmark/cli/commands/scan.py +25 -0
- runmark/cli/commands/share.py +104 -0
- runmark/cli/commands/snapshot.py +39 -0
- runmark/cli/commands/verify.py +75 -0
- runmark/cli/commands/version.py +36 -0
- runmark/contracts/__init__.py +60 -0
- runmark/contracts/canonicalizer.py +119 -0
- runmark/contracts/diff.py +390 -0
- runmark/contracts/discovery.py +33 -0
- runmark/contracts/evaluator.py +724 -0
- runmark/contracts/evidence.py +448 -0
- runmark/contracts/generator.py +107 -0
- runmark/contracts/parser.py +60 -0
- runmark/contracts/security.py +11 -0
- runmark/contracts/validator.py +157 -0
- runmark/contracts/version_constraints.py +234 -0
- runmark/core/__init__.py +32 -0
- runmark/core/contract_check.py +66 -0
- runmark/core/contract_diff.py +65 -0
- runmark/core/contract_init.py +80 -0
- runmark/core/diff.py +522 -0
- runmark/core/doctor.py +240 -0
- runmark/core/reporter.py +163 -0
- runmark/core/scanner.py +173 -0
- runmark/core/snapshot.py +34 -0
- runmark/core/verifier.py +83 -0
- runmark/detectors/__init__.py +86 -0
- runmark/detectors/base.py +66 -0
- runmark/detectors/containers/__init__.py +5 -0
- runmark/detectors/containers/compose.py +82 -0
- runmark/detectors/dependencies/__init__.py +9 -0
- runmark/detectors/dependencies/node.py +185 -0
- runmark/detectors/dependencies/python.py +162 -0
- runmark/detectors/environment/__init__.py +5 -0
- runmark/detectors/environment/env.py +89 -0
- runmark/detectors/git/__init__.py +5 -0
- runmark/detectors/git/git.py +81 -0
- runmark/detectors/network/__init__.py +5 -0
- runmark/detectors/network/ports.py +90 -0
- runmark/detectors/project/__init__.py +11 -0
- runmark/detectors/project/docker.py +48 -0
- runmark/detectors/project/node.py +95 -0
- runmark/detectors/project/python.py +97 -0
- runmark/detectors/registry.py +39 -0
- runmark/detectors/runtimes/__init__.py +13 -0
- runmark/detectors/runtimes/docker.py +59 -0
- runmark/detectors/runtimes/git.py +59 -0
- runmark/detectors/runtimes/node.py +59 -0
- runmark/detectors/runtimes/python.py +62 -0
- runmark/detectors/services/__init__.py +9 -0
- runmark/detectors/services/postgres.py +104 -0
- runmark/detectors/services/redis.py +104 -0
- runmark/detectors/system/__init__.py +5 -0
- runmark/detectors/system/system.py +40 -0
- runmark/models/__init__.py +81 -0
- runmark/models/common.py +36 -0
- runmark/models/container.py +14 -0
- runmark/models/contract.py +122 -0
- runmark/models/contract_result.py +84 -0
- runmark/models/dependency.py +19 -0
- runmark/models/diagnostic.py +133 -0
- runmark/models/environment.py +32 -0
- runmark/models/git.py +16 -0
- runmark/models/network.py +17 -0
- runmark/models/project.py +20 -0
- runmark/models/runmark.py +58 -0
- runmark/models/runtime.py +19 -0
- runmark/models/service.py +21 -0
- runmark/models/system.py +13 -0
- runmark/output/__init__.py +34 -0
- runmark/output/contract.py +337 -0
- runmark/output/json.py +16 -0
- runmark/output/markdown.py +172 -0
- runmark/output/tables.py +300 -0
- runmark/output/terminal.py +70 -0
- runmark/security/__init__.py +29 -0
- runmark/security/contract_sanitizer.py +38 -0
- runmark/security/export_sanitizer.py +142 -0
- runmark/security/redactor.py +75 -0
- runmark/security/sanitizer.py +105 -0
- runmark/security/secret_patterns.py +116 -0
- runmark/storage/__init__.py +6 -0
- runmark/storage/filesystem.py +133 -0
- runmark/storage/paths.py +60 -0
- runmark/utils/__init__.py +20 -0
- runmark/utils/commands.py +117 -0
- runmark/utils/hashing.py +240 -0
- runmark/utils/platform.py +36 -0
- runmark-0.2.1.dist-info/METADATA +149 -0
- runmark-0.2.1.dist-info/RECORD +105 -0
- runmark-0.2.1.dist-info/WHEEL +5 -0
- runmark-0.2.1.dist-info/entry_points.txt +2 -0
- runmark-0.2.1.dist-info/licenses/LICENSE +21 -0
- runmark-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CLI command: runmark scan."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from runmark.core.scanner import Scanner
|
|
8
|
+
from runmark.output.tables import render_scan
|
|
9
|
+
from runmark.output.terminal import term
|
|
10
|
+
from runmark.storage.paths import find_project_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_command(
|
|
14
|
+
path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
|
|
15
|
+
json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Inspect and display the complete runtime, dependency, and service state."""
|
|
18
|
+
root = find_project_root(path)
|
|
19
|
+
scanner = Scanner(root)
|
|
20
|
+
state = scanner.scan()
|
|
21
|
+
|
|
22
|
+
if json_output:
|
|
23
|
+
term.print_json(state)
|
|
24
|
+
else:
|
|
25
|
+
render_scan(state, term.console)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""CLI command: runmark share."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from runmark.core.reporter import Reporter
|
|
9
|
+
from runmark.output.terminal import term
|
|
10
|
+
from runmark.security.export_sanitizer import SecurityViolationError
|
|
11
|
+
from runmark.storage.paths import find_project_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def share_command(
|
|
15
|
+
output: Path | None = typer.Option(
|
|
16
|
+
None,
|
|
17
|
+
"--output",
|
|
18
|
+
"-o",
|
|
19
|
+
help="Target output report path (e.g. report.md, report.json)",
|
|
20
|
+
),
|
|
21
|
+
json_output: bool = typer.Option(
|
|
22
|
+
False,
|
|
23
|
+
"--json",
|
|
24
|
+
help="Generate report in structured JSON format instead of Markdown",
|
|
25
|
+
),
|
|
26
|
+
stdout: bool = typer.Option(
|
|
27
|
+
False,
|
|
28
|
+
"--stdout",
|
|
29
|
+
help="Print only the raw sanitized report content directly to stdout",
|
|
30
|
+
),
|
|
31
|
+
force: bool = typer.Option(
|
|
32
|
+
False,
|
|
33
|
+
"--force",
|
|
34
|
+
"-f",
|
|
35
|
+
help="Overwrite existing output file if it exists",
|
|
36
|
+
),
|
|
37
|
+
path: Path | None = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--path",
|
|
40
|
+
"-p",
|
|
41
|
+
help="Target project root directory (defaults to current directory)",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Generate a sanitized, portable diagnostic report to safely share environment state."""
|
|
45
|
+
if path is not None and not path.exists():
|
|
46
|
+
term.print_error(f"Project directory '{path}' not found.")
|
|
47
|
+
raise typer.Exit(code=2)
|
|
48
|
+
root = find_project_root(path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
report = Reporter.generate_report(root)
|
|
52
|
+
except SecurityViolationError as sec_err:
|
|
53
|
+
term.print_error(str(sec_err))
|
|
54
|
+
raise typer.Exit(code=4) from None
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
term.print_error(f"Failed to generate diagnostic report: {exc}")
|
|
57
|
+
raise typer.Exit(code=3) from None
|
|
58
|
+
|
|
59
|
+
if stdout:
|
|
60
|
+
try:
|
|
61
|
+
if json_output:
|
|
62
|
+
content = Reporter.render_json(report)
|
|
63
|
+
else:
|
|
64
|
+
content = Reporter.render_markdown(report)
|
|
65
|
+
sys.stdout.write(content + "\n")
|
|
66
|
+
sys.stdout.flush()
|
|
67
|
+
return
|
|
68
|
+
except SecurityViolationError as sec_err:
|
|
69
|
+
term.print_error(str(sec_err))
|
|
70
|
+
raise typer.Exit(code=4) from None
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
term.print_error(f"Failed to render report: {exc}")
|
|
73
|
+
raise typer.Exit(code=3) from None
|
|
74
|
+
|
|
75
|
+
dest = output
|
|
76
|
+
if dest is None:
|
|
77
|
+
default_name = "runmark-report.json" if json_output else "runmark-report.md"
|
|
78
|
+
dest = root / default_name
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
out_path = Reporter.export_to_file(
|
|
82
|
+
report=report,
|
|
83
|
+
destination=dest,
|
|
84
|
+
as_json=json_output,
|
|
85
|
+
force=force,
|
|
86
|
+
)
|
|
87
|
+
term.print_success(f"Sanitized diagnostic report written to: {out_path}")
|
|
88
|
+
term.print(
|
|
89
|
+
f"[dim]Report ID: {report.metadata.report_id} | "
|
|
90
|
+
f"Fingerprint: {report.metadata.environment_fingerprint}[/dim]"
|
|
91
|
+
)
|
|
92
|
+
if not json_output:
|
|
93
|
+
term.print(
|
|
94
|
+
"[dim]You can now safely attach this Markdown report to GitHub Issues, Slack, or Discussions.[/dim]"
|
|
95
|
+
)
|
|
96
|
+
except SecurityViolationError as sec_err:
|
|
97
|
+
term.print_error(str(sec_err))
|
|
98
|
+
raise typer.Exit(code=4) from None
|
|
99
|
+
except FileExistsError as file_err:
|
|
100
|
+
term.print_error(str(file_err))
|
|
101
|
+
raise typer.Exit(code=2) from None
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
term.print_error(f"Failed to export report: {exc}")
|
|
104
|
+
raise typer.Exit(code=3) from None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""CLI command: runmark snapshot."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from runmark.core.snapshot import SnapshotManager
|
|
9
|
+
from runmark.output.terminal import term
|
|
10
|
+
from runmark.storage.paths import find_project_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def snapshot_command(
|
|
14
|
+
message: str | None = typer.Option(
|
|
15
|
+
None, "--message", "-m", help="Optional description of the snapshot"
|
|
16
|
+
),
|
|
17
|
+
path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
|
|
18
|
+
json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Capture and persist the current environment state into an immutable baseline snapshot."""
|
|
21
|
+
root = find_project_root(path)
|
|
22
|
+
mgr = SnapshotManager(root)
|
|
23
|
+
state = mgr.create_snapshot(message=message)
|
|
24
|
+
|
|
25
|
+
if json_output:
|
|
26
|
+
term.print_json(state)
|
|
27
|
+
else:
|
|
28
|
+
term.print(
|
|
29
|
+
Panel(
|
|
30
|
+
f"[bold green]✓ Snapshot Captured & Saved[/bold green]\n\n"
|
|
31
|
+
f"• Snapshot ID: [bold cyan]{state.runmark.id}[/bold cyan]\n"
|
|
32
|
+
f"• Fingerprint: [bold green]{state.runmark.environment_fingerprint}[/bold green]\n"
|
|
33
|
+
f"• Tool Version: [dim]{state.runmark.tool_version}[/dim]\n"
|
|
34
|
+
f"• Project: [bold]{state.project.name}[/bold]\n"
|
|
35
|
+
f"• Git Commit: [dim]{state.git.commit or 'none'}[/dim]\n"
|
|
36
|
+
f"{f'• Note: [italic]{state.runmark.message}[/italic]' if state.runmark.message else ''}",
|
|
37
|
+
border_style="green",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CLI command: runmark verify."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from runmark.core.scanner import Scanner
|
|
8
|
+
from runmark.core.snapshot import SnapshotManager
|
|
9
|
+
from runmark.core.verifier import Verifier
|
|
10
|
+
from runmark.models.runmark import RunmarkState
|
|
11
|
+
from runmark.output.tables import render_verify
|
|
12
|
+
from runmark.output.terminal import term
|
|
13
|
+
from runmark.storage.paths import find_project_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_command(
|
|
17
|
+
snapshot: str | None = typer.Option(
|
|
18
|
+
None,
|
|
19
|
+
"--snapshot",
|
|
20
|
+
"-s",
|
|
21
|
+
help="Target baseline snapshot ID (defaults to current active snapshot)",
|
|
22
|
+
),
|
|
23
|
+
strict: bool = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--strict",
|
|
26
|
+
help="Treat warnings as verification failures",
|
|
27
|
+
),
|
|
28
|
+
path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
|
|
29
|
+
json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Verify that current environment matches expected baseline snapshot."""
|
|
32
|
+
root = find_project_root(path)
|
|
33
|
+
mgr = SnapshotManager(root)
|
|
34
|
+
|
|
35
|
+
expected_state: RunmarkState
|
|
36
|
+
if snapshot:
|
|
37
|
+
try:
|
|
38
|
+
expected_state = mgr.get_snapshot(snapshot)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
term.print_error(f"Snapshot '{snapshot}' not found.")
|
|
41
|
+
raise typer.Exit(code=Verifier.EXIT_CODE_INVALID_USAGE) from None
|
|
42
|
+
else:
|
|
43
|
+
loaded_exp = mgr.get_current()
|
|
44
|
+
if not loaded_exp:
|
|
45
|
+
term.print_error(
|
|
46
|
+
"No baseline snapshot found. Create one with 'runmark snapshot' or specify --snapshot."
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(code=Verifier.EXIT_CODE_INVALID_USAGE)
|
|
49
|
+
expected_state = loaded_exp
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
scanner = Scanner(root)
|
|
53
|
+
current_state = scanner.scan()
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
term.print_error(f"Scanning failed during verification: {exc}")
|
|
56
|
+
raise typer.Exit(code=Verifier.EXIT_CODE_INTERNAL_ERROR) from exc
|
|
57
|
+
|
|
58
|
+
verifier = Verifier(strict=strict)
|
|
59
|
+
result = verifier.verify(expected_state, current_state)
|
|
60
|
+
|
|
61
|
+
if json_output:
|
|
62
|
+
term.print_json(
|
|
63
|
+
{
|
|
64
|
+
"status": result.status.value,
|
|
65
|
+
"exit_code": result.exit_code,
|
|
66
|
+
"expected_fingerprint": expected_state.runmark.environment_fingerprint,
|
|
67
|
+
"current_fingerprint": current_state.runmark.environment_fingerprint,
|
|
68
|
+
"reasons": result.reasons,
|
|
69
|
+
"diff_items_count": len(result.diff.environment_items),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
render_verify(result, term.console)
|
|
74
|
+
|
|
75
|
+
raise typer.Exit(code=result.exit_code)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CLI command: runmark version."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from runmark import __schema_version__, __version__
|
|
9
|
+
from runmark.output.terminal import term
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def version_command(
|
|
13
|
+
json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Display Runmark tool version, schema version, and platform information."""
|
|
16
|
+
info = {
|
|
17
|
+
"tool_version": __version__,
|
|
18
|
+
"schema_version": __schema_version__,
|
|
19
|
+
"python_version": platform.python_version(),
|
|
20
|
+
"platform": platform.system(),
|
|
21
|
+
"architecture": platform.machine(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if json_output:
|
|
25
|
+
term.print_json(info)
|
|
26
|
+
else:
|
|
27
|
+
term.print(
|
|
28
|
+
Panel(
|
|
29
|
+
f"[bold cyan]Runmark[/bold cyan] [bold]{__version__}[/bold]\n\n"
|
|
30
|
+
f"• Schema Version: [cyan]{__schema_version__}[/cyan]\n"
|
|
31
|
+
f"• Python Runtime: [dim]{platform.python_version()}[/dim]\n"
|
|
32
|
+
f"• OS / Platform: [dim]{platform.system()} ({platform.machine()})[/dim]\n\n"
|
|
33
|
+
f"[italic dim]Git tracks your code. Runmark tracks what makes your code run.[/italic dim]",
|
|
34
|
+
border_style="cyan",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Runmark environment contracts package."""
|
|
2
|
+
|
|
3
|
+
from runmark.contracts.canonicalizer import ContractCanonicalizer
|
|
4
|
+
from runmark.contracts.diff import (
|
|
5
|
+
ContractChange,
|
|
6
|
+
ContractChangeKind,
|
|
7
|
+
ContractDiffEngine,
|
|
8
|
+
ContractDiffResult,
|
|
9
|
+
ContractDiffSummary,
|
|
10
|
+
)
|
|
11
|
+
from runmark.contracts.discovery import find_contract_file
|
|
12
|
+
from runmark.contracts.evaluator import ContractEvaluator
|
|
13
|
+
from runmark.contracts.evidence import (
|
|
14
|
+
EvidenceCollector,
|
|
15
|
+
EvidenceLevel,
|
|
16
|
+
EvidenceSignal,
|
|
17
|
+
ProjectEvidence,
|
|
18
|
+
)
|
|
19
|
+
from runmark.contracts.generator import ContractGenerator
|
|
20
|
+
from runmark.contracts.parser import ContractParseError, ContractParser
|
|
21
|
+
from runmark.contracts.security import (
|
|
22
|
+
ContractSanitizer,
|
|
23
|
+
ContractSecurityError,
|
|
24
|
+
)
|
|
25
|
+
from runmark.contracts.validator import (
|
|
26
|
+
ContractValidationError,
|
|
27
|
+
ContractValidator,
|
|
28
|
+
)
|
|
29
|
+
from runmark.contracts.version_constraints import (
|
|
30
|
+
ParsedVersion,
|
|
31
|
+
SingleClause,
|
|
32
|
+
VersionConstraint,
|
|
33
|
+
parse_version,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ContractCanonicalizer",
|
|
38
|
+
"ContractChange",
|
|
39
|
+
"ContractChangeKind",
|
|
40
|
+
"ContractDiffEngine",
|
|
41
|
+
"ContractDiffResult",
|
|
42
|
+
"ContractDiffSummary",
|
|
43
|
+
"ContractEvaluator",
|
|
44
|
+
"ContractGenerator",
|
|
45
|
+
"ContractParseError",
|
|
46
|
+
"ContractParser",
|
|
47
|
+
"ContractSanitizer",
|
|
48
|
+
"ContractSecurityError",
|
|
49
|
+
"ContractValidationError",
|
|
50
|
+
"ContractValidator",
|
|
51
|
+
"EvidenceCollector",
|
|
52
|
+
"EvidenceLevel",
|
|
53
|
+
"EvidenceSignal",
|
|
54
|
+
"ParsedVersion",
|
|
55
|
+
"ProjectEvidence",
|
|
56
|
+
"SingleClause",
|
|
57
|
+
"VersionConstraint",
|
|
58
|
+
"find_contract_file",
|
|
59
|
+
"parse_version",
|
|
60
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Contract canonicalization and deterministic fingerprinting."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runmark.models.contract import RunmarkContract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContractCanonicalizer:
|
|
11
|
+
"""Transforms a RunmarkContract into a normalized, canonical data structure and digest."""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def canonicalize(cls, contract: RunmarkContract) -> dict[str, Any]:
|
|
15
|
+
"""Produce a normalized dictionary representing the semantic contract requirements.
|
|
16
|
+
|
|
17
|
+
Excludes informational fields ($schema), normalizes ordering of unordered lists,
|
|
18
|
+
and standardizes key structures.
|
|
19
|
+
"""
|
|
20
|
+
canonical: dict[str, Any] = {
|
|
21
|
+
"version": contract.version,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Project identity
|
|
25
|
+
if contract.project.name:
|
|
26
|
+
canonical["project"] = {"name": contract.project.name}
|
|
27
|
+
else:
|
|
28
|
+
canonical["project"] = {}
|
|
29
|
+
|
|
30
|
+
# Platform requirements (lowercased, deduplicated, sorted)
|
|
31
|
+
clean_os = sorted(
|
|
32
|
+
{os_name.lower().strip() for os_name in contract.platform.os if os_name.strip()}
|
|
33
|
+
)
|
|
34
|
+
clean_arch = sorted(
|
|
35
|
+
{arch.lower().strip() for arch in contract.platform.architecture if arch.strip()}
|
|
36
|
+
)
|
|
37
|
+
canonical["platform"] = {
|
|
38
|
+
"architecture": clean_arch,
|
|
39
|
+
"os": clean_os,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Runtimes (sorted by name, cleaned expressions)
|
|
43
|
+
clean_runtimes: dict[str, str] = {}
|
|
44
|
+
for rt_name in sorted(contract.runtime.keys()):
|
|
45
|
+
clean_runtimes[rt_name.lower().strip()] = contract.runtime[rt_name].strip()
|
|
46
|
+
canonical["runtime"] = clean_runtimes
|
|
47
|
+
|
|
48
|
+
# Dependencies
|
|
49
|
+
clean_py_deps = {
|
|
50
|
+
k.lower().strip(): v.strip() for k, v in sorted(contract.dependencies.python.items())
|
|
51
|
+
}
|
|
52
|
+
clean_node_deps = {
|
|
53
|
+
k.lower().strip(): v.strip() for k, v in sorted(contract.dependencies.node.items())
|
|
54
|
+
}
|
|
55
|
+
canonical["dependencies"] = {
|
|
56
|
+
"node": clean_node_deps,
|
|
57
|
+
"python": clean_py_deps,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Services (sorted by name)
|
|
61
|
+
clean_services: dict[str, dict[str, Any]] = {}
|
|
62
|
+
for svc_name in sorted(contract.services.keys()):
|
|
63
|
+
svc_req = contract.services[svc_name]
|
|
64
|
+
clean_services[svc_name.lower().strip()] = {
|
|
65
|
+
"required": svc_req.required,
|
|
66
|
+
"version": svc_req.version.strip(),
|
|
67
|
+
}
|
|
68
|
+
canonical["services"] = clean_services
|
|
69
|
+
|
|
70
|
+
# Environment variables (sorted, deduplicated)
|
|
71
|
+
clean_required = sorted({v.strip() for v in contract.environment.required if v.strip()})
|
|
72
|
+
clean_optional = sorted({v.strip() for v in contract.environment.optional if v.strip()})
|
|
73
|
+
canonical["environment"] = {
|
|
74
|
+
"optional": clean_optional,
|
|
75
|
+
"required": clean_required,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Network ports (sorted numerically)
|
|
79
|
+
sorted_port_keys = sorted(contract.network.ports.keys(), key=lambda p: int(p))
|
|
80
|
+
clean_ports: dict[str, dict[str, Any]] = {}
|
|
81
|
+
for p_key in sorted_port_keys:
|
|
82
|
+
p_req = contract.network.ports[p_key]
|
|
83
|
+
clean_ports[str(int(p_key))] = {
|
|
84
|
+
"protocol": p_req.protocol.lower().strip(),
|
|
85
|
+
"required": p_req.required,
|
|
86
|
+
}
|
|
87
|
+
canonical["network"] = {"ports": clean_ports}
|
|
88
|
+
|
|
89
|
+
# Containers
|
|
90
|
+
clean_containers: dict[str, Any] = {}
|
|
91
|
+
if contract.containers.docker is not None:
|
|
92
|
+
clean_containers["docker"] = {"required": contract.containers.docker.required}
|
|
93
|
+
if contract.containers.compose is not None:
|
|
94
|
+
clean_containers["compose"] = {"required": contract.containers.compose.required}
|
|
95
|
+
canonical["containers"] = clean_containers
|
|
96
|
+
|
|
97
|
+
return canonical
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def to_canonical_json(cls, contract: RunmarkContract) -> str:
|
|
101
|
+
"""Serialize the canonical representation to a deterministic JSON string."""
|
|
102
|
+
canonical_dict = cls.canonicalize(contract)
|
|
103
|
+
return json.dumps(canonical_dict, sort_keys=True, separators=(",", ":"))
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def to_json(cls, contract: RunmarkContract) -> str:
|
|
107
|
+
"""Alias for to_canonical_json."""
|
|
108
|
+
return cls.to_canonical_json(contract)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def compute_fingerprint(cls, contract: RunmarkContract) -> str:
|
|
112
|
+
"""Compute deterministic SHA-256 digest of the canonical contract representation."""
|
|
113
|
+
canonical_bytes = cls.to_canonical_json(contract).encode("utf-8")
|
|
114
|
+
return hashlib.sha256(canonical_bytes).hexdigest()
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def fingerprint(cls, contract: RunmarkContract) -> str:
|
|
118
|
+
"""Alias for compute_fingerprint."""
|
|
119
|
+
return cls.compute_fingerprint(contract)
|