claude-dev-env 7.0.0 → 7.1.0
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.
- package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
MANIFEST_VERSION = 1
|
|
7
|
+
MANIFEST_VERSION_KEY = "version"
|
|
8
|
+
MANIFEST_CHECKS_KEY = "checks"
|
|
9
|
+
MANIFEST_EXCLUSIONS_KEY = "exclusions"
|
|
10
|
+
CHECK_ID_KEY = "id"
|
|
11
|
+
CHECK_ARGUMENTS_KEY = "argv"
|
|
12
|
+
CHECK_DIRECTORY_KEY = "cwd"
|
|
13
|
+
CHECK_TIMEOUT_KEY = "timeout_seconds"
|
|
14
|
+
CHECK_MINIMUM_TESTS_KEY = "minimum_tests"
|
|
15
|
+
EXCLUSION_SELECTOR_KEY = "selector"
|
|
16
|
+
EXCLUSION_REASON_KEY = "reason"
|
|
17
|
+
MANIFEST_DIGEST_ALGORITHM = "sha256"
|
|
18
|
+
ALL_MANIFEST_DIGEST_SEPARATORS = (",", ":")
|
|
19
|
+
SUCCESS_EXIT_CODE = 0
|
|
20
|
+
CHECK_FAILED_EXIT_CODE = 1
|
|
21
|
+
INVALID_INPUT_EXIT_CODE = 2
|
|
22
|
+
INCOMPLETE_EXIT_CODE = 3
|
|
23
|
+
UTF8_ENCODING = "utf-8"
|
|
24
|
+
JSON_INDENT_SPACES = 2
|
|
25
|
+
PASSED_STATUS = "passed"
|
|
26
|
+
FAILED_STATUS = "failed"
|
|
27
|
+
INCOMPLETE_STATUS = "incomplete"
|
|
28
|
+
MINIMUM_TESTS_ERROR_KIND = "minimum_tests"
|
|
29
|
+
MISSING_TOOL_ERROR_KIND = "missing_tool"
|
|
30
|
+
TIMEOUT_ERROR_KIND = "timeout"
|
|
31
|
+
CRASH_ERROR_KIND = "crash"
|
|
32
|
+
START_ERROR_KIND = "start_error"
|
|
33
|
+
COLLECTION_ERROR_KIND = "collection_error"
|
|
34
|
+
NONZERO_EXIT_ERROR_KIND = "nonzero_exit"
|
|
35
|
+
SKIPPED_TESTS_ERROR_KIND = "skipped_tests"
|
|
36
|
+
PYTEST_COLLECTION_FLAG = "--collect-only"
|
|
37
|
+
PYTEST_QUIET_FLAG = "-q"
|
|
38
|
+
COLLECTED_TESTS_PATTERN = re.compile(r"(?P<count>\d+) tests? collected")
|
|
39
|
+
SKIPPED_TESTS_PATTERN = re.compile(r"(?P<count>\d+) skipped")
|
|
40
|
+
SAFE_LOG_NAME_PATTERN = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
41
|
+
PYTHON_PLACEHOLDER = "{python}"
|
|
42
|
+
REPOSITORY_PLACEHOLDER = "{repo}"
|
|
43
|
+
BASE_PLACEHOLDER = "{base}"
|
|
44
|
+
CDE_LINT_PLACEHOLDER = "{cde_lint}"
|
|
45
|
+
REPOSITORY_POLICY_PLACEHOLDER = "{repository_policy}"
|
|
46
|
+
LOGS_DIRECTORY_SUFFIX = ".logs"
|
|
47
|
+
STDOUT_LOG_SUFFIX = ".stdout.log"
|
|
48
|
+
STDERR_LOG_SUFFIX = ".stderr.log"
|
|
49
|
+
COLLECTION_STDOUT_LOG_SUFFIX = ".collection.stdout.log"
|
|
50
|
+
COLLECTION_STDERR_LOG_SUFFIX = ".collection.stderr.log"
|
|
51
|
+
REPORT_NEWLINE = "\n"
|
|
52
|
+
GIT_EXECUTABLE = "git"
|
|
53
|
+
ALL_GIT_ROOT_ARGUMENTS = ("rev-parse", "--show-toplevel")
|
|
54
|
+
ALL_GIT_HEAD_ARGUMENTS = ("rev-parse", "--verify", "HEAD")
|
|
55
|
+
ALL_GIT_TREE_ARGUMENTS = ("rev-parse", "--verify", "HEAD^{tree}")
|
|
56
|
+
ALL_GIT_BASE_ARGUMENT_PREFIX = ("rev-parse", "--verify")
|
|
57
|
+
ALL_GIT_STATUS_ARGUMENTS = (
|
|
58
|
+
"status",
|
|
59
|
+
"--porcelain=v1",
|
|
60
|
+
"--untracked-files=all",
|
|
61
|
+
)
|
|
62
|
+
ALL_GIT_CHANGED_FILES_ARGUMENTS = ("diff", "--name-only", "-z")
|
|
63
|
+
ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS = (
|
|
64
|
+
"diff",
|
|
65
|
+
"--cached",
|
|
66
|
+
"--name-only",
|
|
67
|
+
"-z",
|
|
68
|
+
)
|
|
69
|
+
ALL_GIT_UNTRACKED_FILES_ARGUMENTS = (
|
|
70
|
+
"ls-files",
|
|
71
|
+
"--others",
|
|
72
|
+
"--exclude-standard",
|
|
73
|
+
"-z",
|
|
74
|
+
)
|
|
75
|
+
GIT_COMMIT_OBJECT_SUFFIX = "^{commit}"
|
|
76
|
+
GIT_PATH_SEPARATOR = b"\0"
|
|
77
|
+
DIGEST_LENGTH_BYTES = 8
|
|
78
|
+
DIGEST_BYTE_ORDER: Literal["big"] = "big"
|
|
79
|
+
CLI_START_MESSAGE_TEMPLATE = "verification start: {report_path}"
|
|
80
|
+
CLI_CHECK_START_MESSAGE_TEMPLATE = "check start: {check_id}"
|
|
81
|
+
CLI_CHECK_FINISH_MESSAGE_TEMPLATE = "check finish: {check_id} status={status}"
|
|
82
|
+
CLI_AGGREGATE_MESSAGE_TEMPLATE = "aggregate: {status}"
|
|
83
|
+
CLI_REVISION_MESSAGE_TEMPLATE = "revision: head={head} base={base}"
|
|
84
|
+
CLI_ELIGIBILITY_MESSAGE_TEMPLATE = "eligibility: clean={worktree_clean} unchanged={inputs_unchanged} publishable={publishable}"
|
|
85
|
+
CLI_REPORT_MESSAGE_TEMPLATE = "report: {report_path}"
|
|
86
|
+
CLI_FAILURE_MESSAGE_TEMPLATE = "verification {status}: required checks did not pass"
|
|
87
|
+
UNRESOLVED_REVISION = "unresolved"
|
|
88
|
+
RUN_LOG_FILENAME = "run.log"
|
|
89
|
+
RUN_CHECK_LOG_TEMPLATE = "check {check_id} status={status}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GIT_METADATA_TIMEOUT_SECONDS = 5.0
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import (
|
|
9
|
+
ALL_GIT_BASE_ARGUMENT_PREFIX,
|
|
10
|
+
ALL_GIT_CHANGED_FILES_ARGUMENTS,
|
|
11
|
+
ALL_GIT_HEAD_ARGUMENTS,
|
|
12
|
+
ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS,
|
|
13
|
+
ALL_GIT_ROOT_ARGUMENTS,
|
|
14
|
+
ALL_GIT_STATUS_ARGUMENTS,
|
|
15
|
+
ALL_GIT_TREE_ARGUMENTS,
|
|
16
|
+
ALL_GIT_UNTRACKED_FILES_ARGUMENTS,
|
|
17
|
+
DIGEST_BYTE_ORDER,
|
|
18
|
+
DIGEST_LENGTH_BYTES,
|
|
19
|
+
GIT_COMMIT_OBJECT_SUFFIX,
|
|
20
|
+
GIT_EXECUTABLE,
|
|
21
|
+
GIT_PATH_SEPARATOR,
|
|
22
|
+
UTF8_ENCODING,
|
|
23
|
+
)
|
|
24
|
+
from .config.timing import GIT_METADATA_TIMEOUT_SECONDS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CandidateSnapshot:
|
|
29
|
+
head_revision: str | None
|
|
30
|
+
base_revision: str | None
|
|
31
|
+
is_git_repository: bool
|
|
32
|
+
worktree_clean: bool
|
|
33
|
+
input_digest: str | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def capture_candidate_snapshot(
|
|
37
|
+
repository_path: Path, base_revision: str
|
|
38
|
+
) -> CandidateSnapshot:
|
|
39
|
+
"""Capture Git revisions and candidate file contents at one point.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
repository_path: Candidate repository root.
|
|
43
|
+
base_revision: Base revision supplied to the verification run.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Git revision and candidate content facts for the snapshot.
|
|
47
|
+
"""
|
|
48
|
+
git_root = _run_git_text(repository_path, ALL_GIT_ROOT_ARGUMENTS)
|
|
49
|
+
if git_root is None or Path(git_root).resolve() != repository_path.resolve():
|
|
50
|
+
return CandidateSnapshot(None, None, False, False, None)
|
|
51
|
+
head_revision = _run_git_text(repository_path, ALL_GIT_HEAD_ARGUMENTS)
|
|
52
|
+
resolved_base_revision = _run_git_text(
|
|
53
|
+
repository_path,
|
|
54
|
+
(*ALL_GIT_BASE_ARGUMENT_PREFIX, base_revision + GIT_COMMIT_OBJECT_SUFFIX),
|
|
55
|
+
)
|
|
56
|
+
status_bytes = _run_git_bytes(repository_path, ALL_GIT_STATUS_ARGUMENTS)
|
|
57
|
+
is_worktree_clean = status_bytes == b""
|
|
58
|
+
return CandidateSnapshot(
|
|
59
|
+
head_revision,
|
|
60
|
+
resolved_base_revision,
|
|
61
|
+
True,
|
|
62
|
+
is_worktree_clean,
|
|
63
|
+
_compute_input_digest(repository_path, is_worktree_clean),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_git_text(repository_path: Path, all_arguments: tuple[str, ...]) -> str | None:
|
|
68
|
+
command_bytes = _run_git_bytes(repository_path, all_arguments)
|
|
69
|
+
if command_bytes is None:
|
|
70
|
+
return None
|
|
71
|
+
revision_text = command_bytes.decode(UTF8_ENCODING).strip()
|
|
72
|
+
return revision_text or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_git_bytes(
|
|
76
|
+
repository_path: Path, all_arguments: tuple[str, ...]
|
|
77
|
+
) -> bytes | None:
|
|
78
|
+
try:
|
|
79
|
+
completed_process = subprocess.run(
|
|
80
|
+
[GIT_EXECUTABLE, *all_arguments],
|
|
81
|
+
cwd=repository_path,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
check=False,
|
|
84
|
+
timeout=GIT_METADATA_TIMEOUT_SECONDS,
|
|
85
|
+
)
|
|
86
|
+
except (OSError, subprocess.SubprocessError):
|
|
87
|
+
return None
|
|
88
|
+
if completed_process.returncode != 0:
|
|
89
|
+
return None
|
|
90
|
+
return completed_process.stdout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compute_input_digest(repository_path: Path, is_worktree_clean: bool) -> str | None:
|
|
94
|
+
head_tree = _run_git_text(repository_path, ALL_GIT_TREE_ARGUMENTS)
|
|
95
|
+
if head_tree is None:
|
|
96
|
+
return None
|
|
97
|
+
input_digest = hashlib.sha256()
|
|
98
|
+
_add_digest_record(input_digest, head_tree.encode(UTF8_ENCODING))
|
|
99
|
+
if is_worktree_clean:
|
|
100
|
+
return input_digest.hexdigest()
|
|
101
|
+
all_changed_paths = _changed_input_paths(repository_path)
|
|
102
|
+
if all_changed_paths is None:
|
|
103
|
+
return None
|
|
104
|
+
for each_path_bytes in all_changed_paths:
|
|
105
|
+
input_path = repository_path / Path(each_path_bytes.decode(UTF8_ENCODING))
|
|
106
|
+
input_bytes = _read_input_bytes(input_path)
|
|
107
|
+
if input_bytes is None:
|
|
108
|
+
return None
|
|
109
|
+
_add_digest_record(input_digest, each_path_bytes)
|
|
110
|
+
_add_digest_record(input_digest, input_bytes)
|
|
111
|
+
return input_digest.hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _changed_input_paths(repository_path: Path) -> tuple[bytes, ...] | None:
|
|
115
|
+
all_path_bytes: set[bytes] = set()
|
|
116
|
+
for each_arguments in (
|
|
117
|
+
ALL_GIT_CHANGED_FILES_ARGUMENTS,
|
|
118
|
+
ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS,
|
|
119
|
+
ALL_GIT_UNTRACKED_FILES_ARGUMENTS,
|
|
120
|
+
):
|
|
121
|
+
changed_path_bytes = _run_git_bytes(repository_path, each_arguments)
|
|
122
|
+
if changed_path_bytes is None:
|
|
123
|
+
return None
|
|
124
|
+
all_path_bytes.update(
|
|
125
|
+
each_path_bytes
|
|
126
|
+
for each_path_bytes in changed_path_bytes.split(GIT_PATH_SEPARATOR)
|
|
127
|
+
if each_path_bytes
|
|
128
|
+
)
|
|
129
|
+
return tuple(sorted(all_path_bytes))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _add_digest_record(input_digest: hashlib._Hash, record_bytes: bytes) -> None:
|
|
133
|
+
input_digest.update(
|
|
134
|
+
len(record_bytes).to_bytes(
|
|
135
|
+
DIGEST_LENGTH_BYTES,
|
|
136
|
+
byteorder=DIGEST_BYTE_ORDER,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
input_digest.update(record_bytes)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_input_bytes(input_path: Path) -> bytes | None:
|
|
143
|
+
try:
|
|
144
|
+
return input_path.read_bytes()
|
|
145
|
+
except OSError:
|
|
146
|
+
return None
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
8
|
+
|
|
9
|
+
from .config import (
|
|
10
|
+
ALL_MANIFEST_DIGEST_SEPARATORS,
|
|
11
|
+
CHECK_ARGUMENTS_KEY,
|
|
12
|
+
CHECK_DIRECTORY_KEY,
|
|
13
|
+
CHECK_ID_KEY,
|
|
14
|
+
CHECK_MINIMUM_TESTS_KEY,
|
|
15
|
+
CHECK_TIMEOUT_KEY,
|
|
16
|
+
EXCLUSION_REASON_KEY,
|
|
17
|
+
EXCLUSION_SELECTOR_KEY,
|
|
18
|
+
MANIFEST_CHECKS_KEY,
|
|
19
|
+
MANIFEST_DIGEST_ALGORITHM,
|
|
20
|
+
MANIFEST_EXCLUSIONS_KEY,
|
|
21
|
+
MANIFEST_VERSION,
|
|
22
|
+
MANIFEST_VERSION_KEY,
|
|
23
|
+
UTF8_ENCODING,
|
|
24
|
+
)
|
|
25
|
+
from .model import CheckSpec, ExclusionSpec, VerificationManifest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ManifestRunFatal(ValueError):
|
|
29
|
+
"""Raised when a verification manifest does not follow version 1."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compute_manifest_digest(manifest: VerificationManifest) -> str:
|
|
33
|
+
"""Return a stable digest for the validated manifest structure.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
manifest: Validated required-check manifest.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A SHA-256 digest that covers every executed manifest field.
|
|
40
|
+
"""
|
|
41
|
+
canonical_manifest = json.dumps(
|
|
42
|
+
_manifest_mapping(manifest),
|
|
43
|
+
sort_keys=True,
|
|
44
|
+
separators=ALL_MANIFEST_DIGEST_SEPARATORS,
|
|
45
|
+
)
|
|
46
|
+
return hashlib.new(
|
|
47
|
+
MANIFEST_DIGEST_ALGORITHM, canonical_manifest.encode(UTF8_ENCODING)
|
|
48
|
+
).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _manifest_mapping(manifest: VerificationManifest) -> dict[str, object]:
|
|
52
|
+
all_check_mappings = [
|
|
53
|
+
{
|
|
54
|
+
CHECK_ID_KEY: each_check.check_id,
|
|
55
|
+
CHECK_ARGUMENTS_KEY: list(each_check.command_arguments),
|
|
56
|
+
CHECK_DIRECTORY_KEY: each_check.cwd,
|
|
57
|
+
CHECK_TIMEOUT_KEY: each_check.timeout_seconds,
|
|
58
|
+
CHECK_MINIMUM_TESTS_KEY: each_check.minimum_tests,
|
|
59
|
+
}
|
|
60
|
+
for each_check in manifest.checks
|
|
61
|
+
]
|
|
62
|
+
all_exclusion_mappings = [
|
|
63
|
+
{
|
|
64
|
+
EXCLUSION_SELECTOR_KEY: each_exclusion.selector,
|
|
65
|
+
EXCLUSION_REASON_KEY: each_exclusion.reason,
|
|
66
|
+
}
|
|
67
|
+
for each_exclusion in manifest.exclusions
|
|
68
|
+
]
|
|
69
|
+
return {
|
|
70
|
+
MANIFEST_VERSION_KEY: manifest.version,
|
|
71
|
+
MANIFEST_CHECKS_KEY: all_check_mappings,
|
|
72
|
+
MANIFEST_EXCLUSIONS_KEY: all_exclusion_mappings,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_manifest(manifest_path: Path) -> VerificationManifest:
|
|
77
|
+
"""Read and validate a required-check manifest.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
manifest_path: JSON file containing the required checks.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The validated manifest.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ManifestRunFatal: If the file cannot describe a valid manifest.
|
|
87
|
+
OSError: If the file cannot be read.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
parsed_manifest: object = json.loads(
|
|
91
|
+
manifest_path.read_text(encoding=UTF8_ENCODING)
|
|
92
|
+
)
|
|
93
|
+
except json.JSONDecodeError as error:
|
|
94
|
+
raise ManifestRunFatal(f"Manifest is not valid JSON: {error}") from error
|
|
95
|
+
manifest_mapping = _require_mapping(parsed_manifest, "manifest")
|
|
96
|
+
manifest_version = _require_integer(manifest_mapping, "version", "manifest")
|
|
97
|
+
if manifest_version != MANIFEST_VERSION:
|
|
98
|
+
raise ManifestRunFatal(f"Manifest version must be {MANIFEST_VERSION}")
|
|
99
|
+
all_checks = _load_checks(manifest_mapping.get("checks"))
|
|
100
|
+
all_exclusions = _load_exclusions(manifest_mapping.get("exclusions", []))
|
|
101
|
+
return VerificationManifest(
|
|
102
|
+
manifest_version, tuple(all_checks), tuple(all_exclusions)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_checks(raw_checks: object) -> list[CheckSpec]:
|
|
107
|
+
if not isinstance(raw_checks, list) or not raw_checks:
|
|
108
|
+
raise ManifestRunFatal("Manifest checks must be a non-empty array")
|
|
109
|
+
all_checks: list[CheckSpec] = []
|
|
110
|
+
all_check_ids: set[str] = set()
|
|
111
|
+
for each_index, each_raw_check in enumerate(raw_checks):
|
|
112
|
+
all_check_fields = _require_mapping(each_raw_check, f"checks[{each_index}]")
|
|
113
|
+
check_spec = _load_check(all_check_fields, each_index)
|
|
114
|
+
if check_spec.check_id in all_check_ids:
|
|
115
|
+
raise ManifestRunFatal("Check ids must be unique")
|
|
116
|
+
all_check_ids.add(check_spec.check_id)
|
|
117
|
+
all_checks.append(check_spec)
|
|
118
|
+
return all_checks
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_check(all_check_fields: Mapping[str, object], check_index: int) -> CheckSpec:
|
|
122
|
+
location = f"checks[{check_index}]"
|
|
123
|
+
check_id = _require_text(all_check_fields, "id", location)
|
|
124
|
+
all_command_arguments = _load_command_arguments(
|
|
125
|
+
all_check_fields.get("argv"), location
|
|
126
|
+
)
|
|
127
|
+
cwd = _load_relative_directory(all_check_fields.get("cwd"), location)
|
|
128
|
+
timeout_seconds = _load_timeout(all_check_fields.get("timeout_seconds"), location)
|
|
129
|
+
minimum_tests = _load_minimum_tests(all_check_fields.get("minimum_tests"), location)
|
|
130
|
+
return CheckSpec(
|
|
131
|
+
check_id, all_command_arguments, cwd, timeout_seconds, minimum_tests
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_command_arguments(raw_arguments: object, location: str) -> tuple[str, ...]:
|
|
136
|
+
if not isinstance(raw_arguments, list) or not raw_arguments:
|
|
137
|
+
raise ManifestRunFatal(f"{location}.argv must be a non-empty array")
|
|
138
|
+
if any(
|
|
139
|
+
not isinstance(each_argument, str) or not each_argument
|
|
140
|
+
for each_argument in raw_arguments
|
|
141
|
+
):
|
|
142
|
+
raise ManifestRunFatal(f"{location}.argv must contain non-empty strings")
|
|
143
|
+
return tuple(raw_arguments)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_exclusions(raw_exclusions: object) -> list[ExclusionSpec]:
|
|
147
|
+
if not isinstance(raw_exclusions, list):
|
|
148
|
+
raise ManifestRunFatal("Manifest exclusions must be an array")
|
|
149
|
+
all_exclusions: list[ExclusionSpec] = []
|
|
150
|
+
for each_index, each_raw_exclusion in enumerate(raw_exclusions):
|
|
151
|
+
all_exclusion_fields = _require_mapping(
|
|
152
|
+
each_raw_exclusion, f"exclusions[{each_index}]"
|
|
153
|
+
)
|
|
154
|
+
location = f"exclusions[{each_index}]"
|
|
155
|
+
selector = _require_text(all_exclusion_fields, "selector", location)
|
|
156
|
+
reason = _require_text(all_exclusion_fields, "reason", location)
|
|
157
|
+
all_exclusions.append(ExclusionSpec(selector, reason))
|
|
158
|
+
return all_exclusions
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _load_relative_directory(raw_directory: object, location: str) -> str:
|
|
162
|
+
if not isinstance(raw_directory, str) or not raw_directory:
|
|
163
|
+
raise ManifestRunFatal(f"{location}.cwd must be a repo-relative directory")
|
|
164
|
+
return _validate_relative_directory(raw_directory, location)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_relative_directory(directory_text: str, location: str) -> str:
|
|
168
|
+
normalized_directory = directory_text.replace("\\", "/")
|
|
169
|
+
directory_path = PurePosixPath(normalized_directory)
|
|
170
|
+
if (
|
|
171
|
+
directory_path.is_absolute()
|
|
172
|
+
or normalized_directory.startswith("/")
|
|
173
|
+
or PureWindowsPath(normalized_directory).drive
|
|
174
|
+
or any(each_part == ".." for each_part in directory_path.parts)
|
|
175
|
+
):
|
|
176
|
+
raise ManifestRunFatal(f"{location}.cwd must stay inside the repository")
|
|
177
|
+
return directory_path.as_posix()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_timeout(raw_timeout: object, location: str) -> float:
|
|
181
|
+
if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)):
|
|
182
|
+
raise ManifestRunFatal(f"{location}.timeout_seconds must be positive")
|
|
183
|
+
timeout_seconds = float(raw_timeout)
|
|
184
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
185
|
+
raise ManifestRunFatal(f"{location}.timeout_seconds must be positive")
|
|
186
|
+
return timeout_seconds
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _load_minimum_tests(raw_minimum: object, location: str) -> int | None:
|
|
190
|
+
if raw_minimum is None:
|
|
191
|
+
return None
|
|
192
|
+
if (
|
|
193
|
+
isinstance(raw_minimum, bool)
|
|
194
|
+
or not isinstance(raw_minimum, int)
|
|
195
|
+
or raw_minimum < 0
|
|
196
|
+
):
|
|
197
|
+
raise ManifestRunFatal(
|
|
198
|
+
f"{location}.minimum_tests must be a non-negative integer"
|
|
199
|
+
)
|
|
200
|
+
return raw_minimum
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _require_mapping(candidate: object, location: str) -> Mapping[str, object]:
|
|
204
|
+
if not isinstance(candidate, Mapping):
|
|
205
|
+
raise ManifestRunFatal(f"{location} must be an object")
|
|
206
|
+
return candidate
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _require_text(
|
|
210
|
+
all_fields: Mapping[str, object], field_name: str, location: str
|
|
211
|
+
) -> str:
|
|
212
|
+
field_text = all_fields.get(field_name)
|
|
213
|
+
if not isinstance(field_text, str) or not field_text.strip():
|
|
214
|
+
raise ManifestRunFatal(f"{location}.{field_name} must be a non-empty string")
|
|
215
|
+
return field_text
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _require_integer(
|
|
219
|
+
all_fields: Mapping[str, object], field_name: str, location: str
|
|
220
|
+
) -> int:
|
|
221
|
+
field_number = all_fields.get(field_name)
|
|
222
|
+
if isinstance(field_number, bool) or not isinstance(field_number, int):
|
|
223
|
+
raise ManifestRunFatal(f"{location}.{field_name} must be an integer")
|
|
224
|
+
return field_number
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
|
|
7
|
+
from .config import (
|
|
8
|
+
CHECK_FAILED_EXIT_CODE,
|
|
9
|
+
FAILED_STATUS,
|
|
10
|
+
INCOMPLETE_EXIT_CODE,
|
|
11
|
+
INCOMPLETE_STATUS,
|
|
12
|
+
PASSED_STATUS,
|
|
13
|
+
SUCCESS_EXIT_CODE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CheckStatus: TypeAlias = str
|
|
17
|
+
AggregateStatus: TypeAlias = str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CheckSpec:
|
|
22
|
+
check_id: str
|
|
23
|
+
command_arguments: tuple[str, ...]
|
|
24
|
+
cwd: str
|
|
25
|
+
timeout_seconds: float
|
|
26
|
+
minimum_tests: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ExclusionSpec:
|
|
31
|
+
selector: str
|
|
32
|
+
reason: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class VerificationManifest:
|
|
37
|
+
version: int
|
|
38
|
+
checks: tuple[CheckSpec, ...]
|
|
39
|
+
exclusions: tuple[ExclusionSpec, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CommandCapture:
|
|
44
|
+
exit_code: int | None
|
|
45
|
+
stdout_text: str
|
|
46
|
+
stderr_text: str
|
|
47
|
+
error_kind: str | None
|
|
48
|
+
error_message: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CheckLogPaths:
|
|
53
|
+
stdout: Path
|
|
54
|
+
stderr: Path
|
|
55
|
+
collection_stdout: Path
|
|
56
|
+
collection_stderr: Path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CheckRecord:
|
|
61
|
+
check_id: str
|
|
62
|
+
status: CheckStatus
|
|
63
|
+
command_arguments: tuple[str, ...]
|
|
64
|
+
cwd: str
|
|
65
|
+
exit_code: int | None
|
|
66
|
+
duration_seconds: float
|
|
67
|
+
stdout_log: Path
|
|
68
|
+
stderr_log: Path
|
|
69
|
+
error_kind: str | None = None
|
|
70
|
+
error_message: str | None = None
|
|
71
|
+
minimum_tests: int | None = None
|
|
72
|
+
collected_tests: int | None = None
|
|
73
|
+
collection_exit_code: int | None = None
|
|
74
|
+
|
|
75
|
+
def as_dict(self) -> dict[str, object]:
|
|
76
|
+
"""Return the JSON record for this check.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A JSON-compatible check record using the manifest argv key.
|
|
80
|
+
"""
|
|
81
|
+
return {
|
|
82
|
+
"id": self.check_id,
|
|
83
|
+
"status": self.status,
|
|
84
|
+
"argv": list(self.command_arguments),
|
|
85
|
+
"cwd": self.cwd,
|
|
86
|
+
"exit_code": self.exit_code,
|
|
87
|
+
"duration_seconds": self.duration_seconds,
|
|
88
|
+
"stdout_log": str(self.stdout_log),
|
|
89
|
+
"stderr_log": str(self.stderr_log),
|
|
90
|
+
"error_kind": self.error_kind,
|
|
91
|
+
"error_message": self.error_message,
|
|
92
|
+
"minimum_tests": self.minimum_tests,
|
|
93
|
+
"collected_tests": self.collected_tests,
|
|
94
|
+
"collection_exit_code": self.collection_exit_code,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class VerificationReport:
|
|
100
|
+
version: int
|
|
101
|
+
repository: Path
|
|
102
|
+
base_revision: str | None
|
|
103
|
+
checks: tuple[CheckRecord, ...]
|
|
104
|
+
exclusions: tuple[ExclusionSpec, ...]
|
|
105
|
+
head_revision: str | None = None
|
|
106
|
+
manifest_digest: str = ""
|
|
107
|
+
is_worktree_clean: bool = False
|
|
108
|
+
is_inputs_unchanged: bool = False
|
|
109
|
+
is_publishable: bool = False
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def worktree_clean(self) -> bool:
|
|
113
|
+
return self.is_worktree_clean
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def inputs_unchanged(self) -> bool:
|
|
117
|
+
return self.is_inputs_unchanged
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def publishable(self) -> bool:
|
|
121
|
+
return self.is_publishable
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def aggregate_status(self) -> AggregateStatus:
|
|
125
|
+
if any(each_record.status == FAILED_STATUS for each_record in self.checks):
|
|
126
|
+
return FAILED_STATUS
|
|
127
|
+
if any(each_record.status == INCOMPLETE_STATUS for each_record in self.checks):
|
|
128
|
+
return INCOMPLETE_STATUS
|
|
129
|
+
return PASSED_STATUS
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def exit_code(self) -> int:
|
|
133
|
+
if self.aggregate_status == FAILED_STATUS:
|
|
134
|
+
return CHECK_FAILED_EXIT_CODE
|
|
135
|
+
if self.aggregate_status == INCOMPLETE_STATUS:
|
|
136
|
+
return INCOMPLETE_EXIT_CODE
|
|
137
|
+
return SUCCESS_EXIT_CODE
|
|
138
|
+
|
|
139
|
+
def _aggregate_fields(self, all_statuses: list[str]) -> dict[str, object]:
|
|
140
|
+
return {
|
|
141
|
+
"status": self.aggregate_status,
|
|
142
|
+
"total": len(self.checks),
|
|
143
|
+
"passed": all_statuses.count(PASSED_STATUS),
|
|
144
|
+
"failed": all_statuses.count(FAILED_STATUS),
|
|
145
|
+
"incomplete": all_statuses.count(INCOMPLETE_STATUS),
|
|
146
|
+
"exit_code": self.exit_code,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def as_dict(self) -> dict[str, object]:
|
|
150
|
+
"""Return the JSON report for this verification run.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A JSON-compatible report with aggregate check counts.
|
|
154
|
+
"""
|
|
155
|
+
all_statuses = [each_record.status for each_record in self.checks]
|
|
156
|
+
return {
|
|
157
|
+
"version": self.version,
|
|
158
|
+
"schema_version": self.version,
|
|
159
|
+
"repository": str(self.repository),
|
|
160
|
+
"head": self.head_revision,
|
|
161
|
+
"base": self.base_revision,
|
|
162
|
+
"manifest_digest": self.manifest_digest,
|
|
163
|
+
"worktree_clean": self.is_worktree_clean,
|
|
164
|
+
"inputs_unchanged": self.is_inputs_unchanged,
|
|
165
|
+
"publishable": self.is_publishable,
|
|
166
|
+
"checks": [each_record.as_dict() for each_record in self.checks],
|
|
167
|
+
"exclusions": [
|
|
168
|
+
{"selector": each_exclusion.selector, "reason": each_exclusion.reason}
|
|
169
|
+
for each_exclusion in self.exclusions
|
|
170
|
+
],
|
|
171
|
+
"aggregate": self._aggregate_fields(all_statuses),
|
|
172
|
+
"status": self.aggregate_status,
|
|
173
|
+
"exit_code": self.exit_code,
|
|
174
|
+
}
|