@ccoalm/ccl-skills 0.7.0 → 0.8.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/README.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +8 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +4 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +11 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +4 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +7 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +8 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +4 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +95 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +54 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +69 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +8 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +8 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +6 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
- package/dist/assets/release.json +175 -70
- package/package.json +1 -1
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Block AI-session and provenance metadata on candidate Git/PR surfaces."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import selectors
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import NoReturn
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MAX_EVENT_BYTES = 1_000_000
|
|
22
|
+
MAX_PR_TEXT_BYTES = 100_000
|
|
23
|
+
MAX_GIT_OUTPUT_BYTES = 8_000_000
|
|
24
|
+
|
|
25
|
+
# ``--repo`` is the one repository identity for this gate. Git otherwise lets
|
|
26
|
+
# ambient variables replace its refs, objects, ancestry, index, or worktree even
|
|
27
|
+
# when every command also supplies ``-C <repo>``. A clean decoy GIT_DIR can then
|
|
28
|
+
# certify a prohibited candidate from another repository. This gate is for a
|
|
29
|
+
# worktree pre-push/CI lane (not a receive-pack quarantine), so none of these
|
|
30
|
+
# routing overrides is a supported input.
|
|
31
|
+
GIT_REPOSITORY_ROUTING_ENV = (
|
|
32
|
+
"GIT_DIR",
|
|
33
|
+
"GIT_WORK_TREE",
|
|
34
|
+
"GIT_IMPLICIT_WORK_TREE",
|
|
35
|
+
"GIT_INDEX_FILE",
|
|
36
|
+
"GIT_COMMON_DIR",
|
|
37
|
+
"GIT_NAMESPACE",
|
|
38
|
+
"GIT_OBJECT_DIRECTORY",
|
|
39
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
40
|
+
"GIT_CEILING_DIRECTORIES",
|
|
41
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
42
|
+
"GIT_GRAFT_FILE",
|
|
43
|
+
"GIT_SHALLOW_FILE",
|
|
44
|
+
"GIT_REPLACE_REF_BASE",
|
|
45
|
+
"GIT_PREFIX",
|
|
46
|
+
"GIT_INTERNAL_SUPER_PREFIX",
|
|
47
|
+
"GIT_QUARANTINE_PATH",
|
|
48
|
+
)
|
|
49
|
+
GIT_CONFIG_ROUTING_ENV = (
|
|
50
|
+
"GIT_CONFIG",
|
|
51
|
+
"GIT_CONFIG_COUNT",
|
|
52
|
+
"GIT_CONFIG_GLOBAL",
|
|
53
|
+
"GIT_CONFIG_SYSTEM",
|
|
54
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
55
|
+
"GIT_CONFIG_PARAMETERS",
|
|
56
|
+
)
|
|
57
|
+
GIT_CONFIG_ROUTING_ENV_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
|
|
58
|
+
|
|
59
|
+
# Keep provider aliases in one registry. Surface patterns may add their own
|
|
60
|
+
# grammar (URL hosts, attribution verbs, analysis labels), but must not carry a
|
|
61
|
+
# second, narrower provider-name list that silently drifts.
|
|
62
|
+
AI_PROVIDER_ALIASES = (
|
|
63
|
+
r"claude(?:\s+code)?",
|
|
64
|
+
r"codex",
|
|
65
|
+
r"chatgpt",
|
|
66
|
+
r"gpt",
|
|
67
|
+
r"openai",
|
|
68
|
+
r"kimi",
|
|
69
|
+
r"opencode",
|
|
70
|
+
r"gemini",
|
|
71
|
+
r"(?:github\s+)?copilot(?:\s+swe\s+agent)?",
|
|
72
|
+
r"cursor",
|
|
73
|
+
r"grok",
|
|
74
|
+
r"qwen",
|
|
75
|
+
r"doubao",
|
|
76
|
+
r"windsurf",
|
|
77
|
+
r"perplexity",
|
|
78
|
+
r"poe",
|
|
79
|
+
)
|
|
80
|
+
AI_PROVIDER = rf"(?:{'|'.join(AI_PROVIDER_ALIASES)})"
|
|
81
|
+
# GitHub App display names use slug separators. Derive that grammar from the
|
|
82
|
+
# one provider registry instead of maintaining a second alias list.
|
|
83
|
+
AI_PROVIDER_ACCOUNT_ALIASES = tuple(
|
|
84
|
+
alias.replace(r"\s+", r"[-_ ]+") for alias in AI_PROVIDER_ALIASES
|
|
85
|
+
)
|
|
86
|
+
AI_PROVIDER_ACCOUNT = rf"(?:{'|'.join(AI_PROVIDER_ACCOUNT_ALIASES)})"
|
|
87
|
+
# These display names are not ordinary personal names. They remain a strong AI
|
|
88
|
+
# attribution signal even when a host or wrapper supplies a conventional email.
|
|
89
|
+
# A Co-Authored-By trailer may additionally use the bot-shaped email signal
|
|
90
|
+
# below; author/committer fields do not, because humans commonly use GitHub
|
|
91
|
+
# noreply privacy addresses.
|
|
92
|
+
AI_IDENTITY_QUALIFIER_TOKEN = (
|
|
93
|
+
r"(?:v?[0-9][A-Za-z0-9.+-]*|code|codex|chatgpt|gpt|swe|agent|assistant|bot|cli|"
|
|
94
|
+
r"reviewer|sonnet|opus|haiku|fable|mythos)"
|
|
95
|
+
)
|
|
96
|
+
AI_IDENTITY_QUALIFIER = rf"(?:[-_\s]+{AI_IDENTITY_QUALIFIER_TOKEN}){{0,3}}"
|
|
97
|
+
AI_IDENTITY_VERSION_INFIX = r"(?:[-_\s]+v?[0-9][A-Za-z0-9.+-]*){0,2}"
|
|
98
|
+
QWEN_MODEL_IDENTITY = (
|
|
99
|
+
rf"qwen(?:[-_\s]*v?[0-9][A-Za-z0-9.+-]*)?[-_\s]+coder"
|
|
100
|
+
rf"{AI_IDENTITY_QUALIFIER}"
|
|
101
|
+
)
|
|
102
|
+
GEMINI_MODEL_IDENTITY = (
|
|
103
|
+
rf"gemini{AI_IDENTITY_VERSION_INFIX}[-_\s]+(?:pro|flash|ultra)"
|
|
104
|
+
rf"{AI_IDENTITY_QUALIFIER}"
|
|
105
|
+
)
|
|
106
|
+
UNAMBIGUOUS_AI_IDENTITY = (
|
|
107
|
+
rf"(?:claude{AI_IDENTITY_VERSION_INFIX}[-_\s]+"
|
|
108
|
+
rf"(?:code|sonnet|opus|haiku|fable|mythos){AI_IDENTITY_QUALIFIER}"
|
|
109
|
+
rf"|{QWEN_MODEL_IDENTITY}|{GEMINI_MODEL_IDENTITY}"
|
|
110
|
+
rf"|(?:codex|chatgpt|gpt|openai|opencode|(?:github\s+)?copilot|qwen|"
|
|
111
|
+
rf"doubao|windsurf|perplexity){AI_IDENTITY_QUALIFIER})"
|
|
112
|
+
)
|
|
113
|
+
BRACKETED_AI_BOT_IDENTITY = rf"(?:{AI_PROVIDER_ACCOUNT})\[bot\]"
|
|
114
|
+
UNAMBIGUOUS_AI_DISPLAY_NAME = (
|
|
115
|
+
rf"(?:{UNAMBIGUOUS_AI_IDENTITY}|{BRACKETED_AI_BOT_IDENTITY})"
|
|
116
|
+
)
|
|
117
|
+
AI_BRACKETED_BOT_EMAIL_IDENTITY = (
|
|
118
|
+
rf"[^<>\r\n]+[ \t]*<(?:[0-9]+\+)?"
|
|
119
|
+
rf"{BRACKETED_AI_BOT_IDENTITY}@[^>\r\n]*>"
|
|
120
|
+
)
|
|
121
|
+
# Emphasis wrapped around just the identity ("**Claude Code**") must not
|
|
122
|
+
# defeat the anchored trailer rule; whole-line wrappers are unwrapped later.
|
|
123
|
+
INLINE_EMPHASIS_WRAP = r"[*_~`]*"
|
|
124
|
+
AI_ATTRIBUTION_IDENTITY = (
|
|
125
|
+
rf"(?:{INLINE_EMPHASIS_WRAP}{UNAMBIGUOUS_AI_DISPLAY_NAME}"
|
|
126
|
+
rf"{INLINE_EMPHASIS_WRAP}[ \t]*<[^>\r\n]*>"
|
|
127
|
+
rf"|{INLINE_EMPHASIS_WRAP}{AI_PROVIDER}{INLINE_EMPHASIS_WRAP}[ \t]*<[^>\r\n]*"
|
|
128
|
+
r"(?<![A-Za-z0-9])(?:no-?reply|bot)(?![A-Za-z0-9])"
|
|
129
|
+
rf"[^>\r\n]*>|{AI_BRACKETED_BOT_EMAIL_IDENTITY})"
|
|
130
|
+
)
|
|
131
|
+
AI_COMMIT_IDENTITY = re.compile(
|
|
132
|
+
rf"^(?:{UNAMBIGUOUS_AI_DISPLAY_NAME}[ \t]*<[^>\r\n]*>"
|
|
133
|
+
rf"|{AI_BRACKETED_BOT_EMAIL_IDENTITY})[ \t]*$",
|
|
134
|
+
re.IGNORECASE,
|
|
135
|
+
)
|
|
136
|
+
MARKDOWN_LINE_PREFIX = (
|
|
137
|
+
r"[ \t]*(?:(?:>[ \t]*)"
|
|
138
|
+
r"|(?:[-*+][ \t]+(?:\[[ xX]\][ \t]+)?)"
|
|
139
|
+
r"|(?:[0-9]{1,9}[.)][ \t]+(?:\[[ xX]\][ \t]+)?)"
|
|
140
|
+
r"|(?:#{1,6}[ \t]+))*"
|
|
141
|
+
)
|
|
142
|
+
MARKDOWN_LINE_PREFIX_RE = re.compile(MARKDOWN_LINE_PREFIX)
|
|
143
|
+
INLINE_MARKDOWN_WRAPPERS = ("```", "**", "__", "~~", "``", "*", "_", "`")
|
|
144
|
+
GENERIC_AI_ACTOR = r"(?:ai|llm)"
|
|
145
|
+
AI_SESSION_ACTOR = rf"(?:{AI_PROVIDER}|{GENERIC_AI_ACTOR})"
|
|
146
|
+
# Keep the two verified human-name collisions precise without treating every
|
|
147
|
+
# capitalized model/product continuation (Model, Pro, Sonnet, ...) as a surname.
|
|
148
|
+
KNOWN_HUMAN_CREDIT_GUARD = r"(?!(?:ai[ \t]+weiwei|claude[ \t]+monet)\b)"
|
|
149
|
+
AI_GENERATOR = (
|
|
150
|
+
rf"(?:{UNAMBIGUOUS_AI_IDENTITY}|generative\s+ai|large\s+language\s+model"
|
|
151
|
+
rf"|{KNOWN_HUMAN_CREDIT_GUARD}(?:{AI_PROVIDER}|{GENERIC_AI_ACTOR}))"
|
|
152
|
+
)
|
|
153
|
+
AI_SESSION_ORIGIN_ALIASES = (
|
|
154
|
+
(r"(?:www\.)?claude\.ai", ""),
|
|
155
|
+
(r"(?:www\.)?chatgpt\.com", ""),
|
|
156
|
+
(r"chat\.openai\.com", ""),
|
|
157
|
+
(r"(?:www\.)?kimi\.com", ""),
|
|
158
|
+
(r"gemini\.google\.com", ""),
|
|
159
|
+
(r"g\.co", "gemini/"),
|
|
160
|
+
(r"copilot\.microsoft\.com", ""),
|
|
161
|
+
(r"(?:www\.)?opencode\.ai", ""),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ai_session_origins(default_port: str) -> str:
|
|
166
|
+
return "(?:" + "|".join(
|
|
167
|
+
rf"{host}(?::{default_port})?/{path_prefix}"
|
|
168
|
+
for host, path_prefix in AI_SESSION_ORIGIN_ALIASES
|
|
169
|
+
) + ")"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
AI_SESSION_HTTPS_ORIGIN = ai_session_origins("443")
|
|
173
|
+
AI_SESSION_HTTP_ORIGIN = ai_session_origins("80")
|
|
174
|
+
# Stop a failed candidate at the next URL scheme. Without this boundary, a
|
|
175
|
+
# greedy path rescan starts again at every repeated recognized origin and turns
|
|
176
|
+
# a bounded 100 KB input into quadratic work.
|
|
177
|
+
AI_SESSION_PATH_CHAR = r"(?:(?!https?://)[^\s<>\"'])"
|
|
178
|
+
CANONICAL_UUID = (
|
|
179
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
180
|
+
r"[0-9a-f]{4}-[0-9a-f]{12}"
|
|
181
|
+
)
|
|
182
|
+
BRANCH_SEPARATOR_CHAR = r"[-_/\.]"
|
|
183
|
+
BRANCH_SEPARATOR = rf"{BRANCH_SEPARATOR_CHAR}+"
|
|
184
|
+
BRANCH_START = rf"(?:^|(?<={BRANCH_SEPARATOR_CHAR}))"
|
|
185
|
+
BRANCH_BOUNDARY = rf"(?=$|{BRANCH_SEPARATOR_CHAR})"
|
|
186
|
+
AI_PROVIDER_BRANCH_ALIASES = tuple(
|
|
187
|
+
alias.replace(r"\s+", BRANCH_SEPARATOR) for alias in AI_PROVIDER_ALIASES
|
|
188
|
+
)
|
|
189
|
+
AI_PROVIDER_BRANCH = rf"(?:{'|'.join(AI_PROVIDER_BRANCH_ALIASES)})"
|
|
190
|
+
AI_SESSION_BRANCH_ACTOR = rf"(?:{AI_PROVIDER_BRANCH}|{GENERIC_AI_ACTOR})"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class Pattern:
|
|
195
|
+
category: str
|
|
196
|
+
regex: re.Pattern[str]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
PATTERNS = (
|
|
200
|
+
Pattern(
|
|
201
|
+
"ai_session_url",
|
|
202
|
+
re.compile(
|
|
203
|
+
rf"(?:(?:https://{AI_SESSION_HTTPS_ORIGIN})"
|
|
204
|
+
rf"|(?:http://{AI_SESSION_HTTP_ORIGIN}))(?:"
|
|
205
|
+
rf"(?:c|chat)/{CANONICAL_UUID}"
|
|
206
|
+
rf"|{AI_SESSION_PATH_CHAR}*(?:(?:session|shares?)[_/-]|code/"
|
|
207
|
+
r"|codex/tasks?[_/-])"
|
|
208
|
+
r"[A-Za-z0-9][A-Za-z0-9_-]{5,})",
|
|
209
|
+
re.IGNORECASE,
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
Pattern(
|
|
213
|
+
"ai_session_id",
|
|
214
|
+
re.compile(
|
|
215
|
+
rf"\b{AI_SESSION_ACTOR}[-_ ]?session(?:[-_ ]?id)?"
|
|
216
|
+
r"\s*[:=_-]\s*[A-Za-z0-9][A-Za-z0-9_-]{5,}",
|
|
217
|
+
re.IGNORECASE,
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
Pattern(
|
|
221
|
+
"ai_session_trailer",
|
|
222
|
+
re.compile(
|
|
223
|
+
rf"^{MARKDOWN_LINE_PREFIX}{AI_SESSION_ACTOR}"
|
|
224
|
+
rf"[-_ ]session(?:[-_ ]id)?\s*:\s*\S+",
|
|
225
|
+
re.IGNORECASE | re.MULTILINE,
|
|
226
|
+
),
|
|
227
|
+
),
|
|
228
|
+
Pattern(
|
|
229
|
+
"ai_coauthor_trailer",
|
|
230
|
+
re.compile(
|
|
231
|
+
rf"^{MARKDOWN_LINE_PREFIX}co-authored-by\s*:"
|
|
232
|
+
rf"\s*{AI_ATTRIBUTION_IDENTITY}[ \t]*$",
|
|
233
|
+
re.IGNORECASE | re.MULTILINE,
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
Pattern(
|
|
237
|
+
"generated_by_footer",
|
|
238
|
+
re.compile(
|
|
239
|
+
rf"^{MARKDOWN_LINE_PREFIX}(?:(?:🤖[ \t]*)?"
|
|
240
|
+
rf"(?:generated|created|written|built|authored|produced)"
|
|
241
|
+
rf"[ \t]+(?:with|by|using)[ \t]+[\[(<\"'*_~]*{AI_GENERATOR}\b.*"
|
|
242
|
+
rf"|🤖[ \t]*{AI_PROVIDER})[ \t]*$",
|
|
243
|
+
re.IGNORECASE | re.MULTILINE,
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
Pattern(
|
|
247
|
+
"conversation_process",
|
|
248
|
+
re.compile(
|
|
249
|
+
rf"^{MARKDOWN_LINE_PREFIX}(?:用户原话|会话过程|模型分析"
|
|
250
|
+
rf"|{AI_SESSION_ACTOR}\s*分析"
|
|
251
|
+
rf"|user\s+prompt|conversation\s+(?:process|transcript)"
|
|
252
|
+
rf"|{AI_SESSION_ACTOR}\s+(?:analysis|reasoning))\s*[::]",
|
|
253
|
+
re.IGNORECASE | re.MULTILINE,
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
Pattern(
|
|
257
|
+
"provenance_wording",
|
|
258
|
+
re.compile(
|
|
259
|
+
r"(?:外部|他人|模型|会话|来源|出处).{0,16}(?:吸收|借鉴)"
|
|
260
|
+
r"|(?:吸收|借鉴).{0,16}(?:外部|他人|模型|会话|来源|出处)"
|
|
261
|
+
r"|\b(?:inspired\s+(?:by|from)|(?:adapted|borrowed)\s+from)\s+"
|
|
262
|
+
rf"(?:{UNAMBIGUOUS_AI_IDENTITY}|{KNOWN_HUMAN_CREDIT_GUARD}"
|
|
263
|
+
rf"{AI_PROVIDER}|another\s+(?:agent|model)"
|
|
264
|
+
r"|external\s+(?:practice|source))\b",
|
|
265
|
+
re.IGNORECASE,
|
|
266
|
+
),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Branch refs use slug separators instead of prose whitespace or trailer
|
|
271
|
+
# punctuation. Keep this grammar branch-only so normal prose keeps the tighter
|
|
272
|
+
# line-oriented precision above.
|
|
273
|
+
BRANCH_PATTERNS = (
|
|
274
|
+
Pattern(
|
|
275
|
+
"provenance_wording",
|
|
276
|
+
re.compile(
|
|
277
|
+
rf"{BRANCH_START}(?:inspired{BRANCH_SEPARATOR}(?:by|from)"
|
|
278
|
+
rf"|(?:adapted|borrowed){BRANCH_SEPARATOR}from)"
|
|
279
|
+
rf"{BRANCH_SEPARATOR}{AI_PROVIDER_BRANCH}{BRANCH_BOUNDARY}",
|
|
280
|
+
re.IGNORECASE,
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
Pattern(
|
|
284
|
+
"generated_by_footer",
|
|
285
|
+
re.compile(
|
|
286
|
+
rf"{BRANCH_START}(?:generated|created|written|built|authored|produced)"
|
|
287
|
+
rf"{BRANCH_SEPARATOR}(?:with|by|using){BRANCH_SEPARATOR}"
|
|
288
|
+
rf"{AI_SESSION_BRANCH_ACTOR}{BRANCH_BOUNDARY}",
|
|
289
|
+
re.IGNORECASE,
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
Pattern(
|
|
293
|
+
"ai_coauthor_trailer",
|
|
294
|
+
re.compile(
|
|
295
|
+
rf"{BRANCH_START}co{BRANCH_SEPARATOR}authored{BRANCH_SEPARATOR}by"
|
|
296
|
+
rf"{BRANCH_SEPARATOR}{AI_SESSION_BRANCH_ACTOR}{BRANCH_BOUNDARY}",
|
|
297
|
+
re.IGNORECASE,
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
Pattern(
|
|
301
|
+
"conversation_process",
|
|
302
|
+
re.compile(
|
|
303
|
+
rf"{BRANCH_START}(?:{AI_SESSION_BRANCH_ACTOR}{BRANCH_SEPARATOR}"
|
|
304
|
+
rf"(?:analysis|reasoning|分析)|user{BRANCH_SEPARATOR}prompt"
|
|
305
|
+
rf"|conversation{BRANCH_SEPARATOR}(?:process|transcript)"
|
|
306
|
+
rf"|用户原话|会话过程|模型分析){BRANCH_BOUNDARY}",
|
|
307
|
+
re.IGNORECASE,
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class GateError(Exception):
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def fail(detail: str) -> NoReturn:
|
|
318
|
+
raise GateError(detail)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def read_fd(fd: int, size: int) -> bytes:
|
|
322
|
+
return os.read(fd, size)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def git_environment() -> dict[str, str]:
|
|
326
|
+
environment = os.environ.copy()
|
|
327
|
+
for key in tuple(environment):
|
|
328
|
+
if (
|
|
329
|
+
key in GIT_REPOSITORY_ROUTING_ENV
|
|
330
|
+
or key in GIT_CONFIG_ROUTING_ENV
|
|
331
|
+
or key.startswith(GIT_CONFIG_ROUTING_ENV_PREFIXES)
|
|
332
|
+
):
|
|
333
|
+
environment.pop(key, None)
|
|
334
|
+
return environment
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def stop_process(process: subprocess.Popen[bytes]) -> None:
|
|
338
|
+
if process.poll() is None:
|
|
339
|
+
try:
|
|
340
|
+
process.kill()
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
try:
|
|
344
|
+
process.wait(timeout=1)
|
|
345
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def git(
|
|
350
|
+
repo: Path,
|
|
351
|
+
*args: str,
|
|
352
|
+
max_bytes: int = MAX_GIT_OUTPUT_BYTES,
|
|
353
|
+
operation: str = "git command",
|
|
354
|
+
input_bytes: bytes | None = None,
|
|
355
|
+
) -> bytes:
|
|
356
|
+
if max_bytes < 0:
|
|
357
|
+
fail(f"{operation} has an invalid safety limit")
|
|
358
|
+
if input_bytes is not None and len(input_bytes) > MAX_GIT_OUTPUT_BYTES:
|
|
359
|
+
fail(f"{operation} input exceeds the safety limit")
|
|
360
|
+
input_stream = None
|
|
361
|
+
try:
|
|
362
|
+
if input_bytes is not None:
|
|
363
|
+
input_stream = tempfile.TemporaryFile()
|
|
364
|
+
input_stream.write(input_bytes)
|
|
365
|
+
input_stream.seek(0)
|
|
366
|
+
process = subprocess.Popen(
|
|
367
|
+
["git", "--no-replace-objects", "-C", str(repo), *args],
|
|
368
|
+
stdin=input_stream,
|
|
369
|
+
stdout=subprocess.PIPE,
|
|
370
|
+
stderr=subprocess.DEVNULL,
|
|
371
|
+
env=git_environment(),
|
|
372
|
+
)
|
|
373
|
+
except (OSError, ValueError):
|
|
374
|
+
fail(f"{operation} could not run")
|
|
375
|
+
finally:
|
|
376
|
+
if input_stream is not None:
|
|
377
|
+
input_stream.close()
|
|
378
|
+
if process.stdout is None:
|
|
379
|
+
stop_process(process)
|
|
380
|
+
fail(f"{operation} could not capture output")
|
|
381
|
+
|
|
382
|
+
selector = selectors.DefaultSelector()
|
|
383
|
+
chunks: list[bytes] = []
|
|
384
|
+
total = 0
|
|
385
|
+
deadline = time.monotonic() + 30
|
|
386
|
+
returncode: int | None = None
|
|
387
|
+
try:
|
|
388
|
+
selector.register(process.stdout, selectors.EVENT_READ)
|
|
389
|
+
while True:
|
|
390
|
+
remaining = deadline - time.monotonic()
|
|
391
|
+
if remaining <= 0:
|
|
392
|
+
fail(f"{operation} timed out")
|
|
393
|
+
try:
|
|
394
|
+
ready = selector.select(remaining)
|
|
395
|
+
except OSError:
|
|
396
|
+
fail(f"{operation} failed")
|
|
397
|
+
if not ready:
|
|
398
|
+
fail(f"{operation} timed out")
|
|
399
|
+
try:
|
|
400
|
+
chunk = read_fd(
|
|
401
|
+
process.stdout.fileno(), min(65_536, max_bytes + 1 - total)
|
|
402
|
+
)
|
|
403
|
+
except OSError:
|
|
404
|
+
fail(f"{operation} failed")
|
|
405
|
+
if not chunk:
|
|
406
|
+
break
|
|
407
|
+
total += len(chunk)
|
|
408
|
+
if total > max_bytes:
|
|
409
|
+
fail(f"{operation} output exceeds the safety limit")
|
|
410
|
+
chunks.append(chunk)
|
|
411
|
+
|
|
412
|
+
remaining = deadline - time.monotonic()
|
|
413
|
+
if remaining <= 0:
|
|
414
|
+
fail(f"{operation} timed out")
|
|
415
|
+
try:
|
|
416
|
+
returncode = process.wait(timeout=remaining)
|
|
417
|
+
except subprocess.TimeoutExpired:
|
|
418
|
+
fail(f"{operation} timed out")
|
|
419
|
+
except BaseException:
|
|
420
|
+
stop_process(process)
|
|
421
|
+
raise
|
|
422
|
+
finally:
|
|
423
|
+
selector.close()
|
|
424
|
+
try:
|
|
425
|
+
process.stdout.close()
|
|
426
|
+
except OSError:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
if returncode != 0:
|
|
430
|
+
fail(f"{operation} failed")
|
|
431
|
+
return b"".join(chunks)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def git_text(repo: Path, *args: str, operation: str = "git command") -> str:
|
|
435
|
+
return git(repo, *args, operation=operation).decode(
|
|
436
|
+
"utf-8", errors="replace"
|
|
437
|
+
).strip()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def reject_git_grafts(repo: Path) -> None:
|
|
441
|
+
raw_path = git(
|
|
442
|
+
repo,
|
|
443
|
+
"rev-parse",
|
|
444
|
+
"--git-path",
|
|
445
|
+
"info/grafts",
|
|
446
|
+
max_bytes=16_384,
|
|
447
|
+
operation="candidate graft path resolution",
|
|
448
|
+
)
|
|
449
|
+
try:
|
|
450
|
+
path_text = raw_path.decode("utf-8", errors="strict").strip()
|
|
451
|
+
except UnicodeDecodeError:
|
|
452
|
+
fail("candidate graft path is not valid UTF-8")
|
|
453
|
+
if not path_text or "\x00" in path_text:
|
|
454
|
+
fail("candidate graft path is invalid")
|
|
455
|
+
graft_path = Path(path_text)
|
|
456
|
+
if not graft_path.is_absolute():
|
|
457
|
+
graft_path = repo / graft_path
|
|
458
|
+
|
|
459
|
+
if not hasattr(os, "O_NOFOLLOW"):
|
|
460
|
+
fail("candidate graft inspection is unavailable")
|
|
461
|
+
flags = os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW
|
|
462
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
463
|
+
flags |= os.O_CLOEXEC
|
|
464
|
+
try:
|
|
465
|
+
fd = os.open(graft_path, flags)
|
|
466
|
+
except FileNotFoundError:
|
|
467
|
+
return
|
|
468
|
+
except OSError:
|
|
469
|
+
fail("candidate graft state is unreadable")
|
|
470
|
+
try:
|
|
471
|
+
try:
|
|
472
|
+
info = os.fstat(fd)
|
|
473
|
+
except OSError:
|
|
474
|
+
fail("candidate graft state is unreadable")
|
|
475
|
+
if not stat.S_ISREG(info.st_mode):
|
|
476
|
+
fail("candidate graft state is unsafe")
|
|
477
|
+
try:
|
|
478
|
+
first_byte = read_fd(fd, 1)
|
|
479
|
+
except OSError:
|
|
480
|
+
fail("candidate graft state is unreadable")
|
|
481
|
+
finally:
|
|
482
|
+
try:
|
|
483
|
+
os.close(fd)
|
|
484
|
+
except OSError:
|
|
485
|
+
pass
|
|
486
|
+
if first_byte:
|
|
487
|
+
fail("candidate history uses unsupported grafts")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def read_regular(path: Path, *, max_bytes: int, label: str) -> str:
|
|
491
|
+
if not hasattr(os, "O_NOFOLLOW"):
|
|
492
|
+
fail(f"{label} secure reading is unavailable")
|
|
493
|
+
flags = os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW
|
|
494
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
495
|
+
flags |= os.O_CLOEXEC
|
|
496
|
+
try:
|
|
497
|
+
fd = os.open(path, flags)
|
|
498
|
+
except OSError:
|
|
499
|
+
fail(f"{label} unreadable")
|
|
500
|
+
try:
|
|
501
|
+
try:
|
|
502
|
+
info = os.fstat(fd)
|
|
503
|
+
except OSError:
|
|
504
|
+
fail(f"{label} unreadable")
|
|
505
|
+
if not stat.S_ISREG(info.st_mode):
|
|
506
|
+
fail(f"{label} must be a regular, non-symlink file")
|
|
507
|
+
if info.st_size > max_bytes:
|
|
508
|
+
fail(f"{label} exceeds the safety limit")
|
|
509
|
+
|
|
510
|
+
chunks: list[bytes] = []
|
|
511
|
+
total = 0
|
|
512
|
+
while total <= max_bytes:
|
|
513
|
+
try:
|
|
514
|
+
chunk = read_fd(fd, min(65_536, max_bytes + 1 - total))
|
|
515
|
+
except OSError:
|
|
516
|
+
fail(f"{label} unreadable")
|
|
517
|
+
if not chunk:
|
|
518
|
+
break
|
|
519
|
+
chunks.append(chunk)
|
|
520
|
+
total += len(chunk)
|
|
521
|
+
data = b"".join(chunks)
|
|
522
|
+
finally:
|
|
523
|
+
try:
|
|
524
|
+
os.close(fd)
|
|
525
|
+
except OSError:
|
|
526
|
+
pass
|
|
527
|
+
if len(data) > max_bytes:
|
|
528
|
+
fail(f"{label} exceeds the safety limit")
|
|
529
|
+
try:
|
|
530
|
+
return data.decode("utf-8")
|
|
531
|
+
except UnicodeDecodeError as exc:
|
|
532
|
+
fail(f"{label} is not UTF-8 at byte {exc.start}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def event_path(args: argparse.Namespace) -> Path | None:
|
|
536
|
+
if args.event_json is not None:
|
|
537
|
+
return args.event_json
|
|
538
|
+
raw = os.environ.get("GITHUB_EVENT_PATH", "")
|
|
539
|
+
return Path(raw) if raw else None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def load_event(path: Path | None) -> dict[str, object] | None:
|
|
543
|
+
if path is None:
|
|
544
|
+
return None
|
|
545
|
+
raw = read_regular(path, max_bytes=MAX_EVENT_BYTES, label="event JSON")
|
|
546
|
+
try:
|
|
547
|
+
parsed = json.loads(raw)
|
|
548
|
+
except json.JSONDecodeError as exc:
|
|
549
|
+
fail(f"event JSON is malformed at line {exc.lineno} column {exc.colno}")
|
|
550
|
+
if not isinstance(parsed, dict):
|
|
551
|
+
fail("event JSON top level must be an object")
|
|
552
|
+
return parsed
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def pull_request_fields(event: dict[str, object] | None) -> list[tuple[str, str]]:
|
|
556
|
+
if event is None or "pull_request" not in event:
|
|
557
|
+
return []
|
|
558
|
+
pr = event["pull_request"]
|
|
559
|
+
if not isinstance(pr, dict):
|
|
560
|
+
fail("event pull_request must be an object")
|
|
561
|
+
title = pr.get("title")
|
|
562
|
+
body = pr.get("body")
|
|
563
|
+
if not isinstance(title, str):
|
|
564
|
+
fail("event pull_request.title must be a string")
|
|
565
|
+
if body is not None and not isinstance(body, str):
|
|
566
|
+
fail("event pull_request.body must be a string or null")
|
|
567
|
+
return [("pull_request_title", title), ("pull_request_body", body or "")]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def event_base(event: dict[str, object] | None) -> str | None:
|
|
571
|
+
if event is None:
|
|
572
|
+
return None
|
|
573
|
+
pr = event.get("pull_request")
|
|
574
|
+
if isinstance(pr, dict):
|
|
575
|
+
base = pr.get("base")
|
|
576
|
+
if isinstance(base, dict) and isinstance(base.get("sha"), str):
|
|
577
|
+
return base["sha"]
|
|
578
|
+
before = event.get("before")
|
|
579
|
+
return before if isinstance(before, str) and before.strip("0") else None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def is_branch_push_without_base(event: dict[str, object] | None) -> bool:
|
|
583
|
+
if event is None or "pull_request" in event:
|
|
584
|
+
return False
|
|
585
|
+
ref = event.get("ref")
|
|
586
|
+
if not isinstance(ref, str) or not ref.startswith("refs/heads/"):
|
|
587
|
+
return False
|
|
588
|
+
before = event.get("before")
|
|
589
|
+
return not (isinstance(before, str) and before.strip("0"))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def event_branch(event: dict[str, object] | None) -> str | None:
|
|
593
|
+
if event is None:
|
|
594
|
+
return None
|
|
595
|
+
pr = event.get("pull_request")
|
|
596
|
+
if isinstance(pr, dict):
|
|
597
|
+
head = pr.get("head")
|
|
598
|
+
if isinstance(head, dict) and isinstance(head.get("ref"), str):
|
|
599
|
+
return head["ref"]
|
|
600
|
+
ref = event.get("ref")
|
|
601
|
+
if isinstance(ref, str) and ref.startswith("refs/heads/"):
|
|
602
|
+
return ref.removeprefix("refs/heads/")
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def resolve_commit(
|
|
607
|
+
repo: Path,
|
|
608
|
+
ref: str,
|
|
609
|
+
*,
|
|
610
|
+
label: str,
|
|
611
|
+
unresolved_detail: str | None = None,
|
|
612
|
+
) -> str:
|
|
613
|
+
try:
|
|
614
|
+
result = subprocess.run(
|
|
615
|
+
[
|
|
616
|
+
"git",
|
|
617
|
+
"--no-replace-objects",
|
|
618
|
+
"-C",
|
|
619
|
+
str(repo),
|
|
620
|
+
"rev-parse",
|
|
621
|
+
"--verify",
|
|
622
|
+
"--quiet",
|
|
623
|
+
"--end-of-options",
|
|
624
|
+
f"{ref}^{{commit}}",
|
|
625
|
+
],
|
|
626
|
+
stdout=subprocess.PIPE,
|
|
627
|
+
stderr=subprocess.DEVNULL,
|
|
628
|
+
check=False,
|
|
629
|
+
timeout=30,
|
|
630
|
+
env=git_environment(),
|
|
631
|
+
)
|
|
632
|
+
except subprocess.TimeoutExpired:
|
|
633
|
+
fail(f"{label} resolution timed out")
|
|
634
|
+
except (OSError, ValueError):
|
|
635
|
+
fail(f"{label} resolution could not run")
|
|
636
|
+
if result.returncode != 0:
|
|
637
|
+
fail(unresolved_detail or f"{label} does not resolve")
|
|
638
|
+
oid = result.stdout.strip()
|
|
639
|
+
if not re.fullmatch(rb"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", oid):
|
|
640
|
+
fail(f"{label} resolved to an invalid object id")
|
|
641
|
+
return oid.decode("ascii").lower()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def resolve_head(repo: Path, requested: str | None) -> str:
|
|
645
|
+
return resolve_commit(repo, requested or "HEAD", label="candidate head")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def resolve_base(
|
|
649
|
+
repo: Path,
|
|
650
|
+
requested: str | None,
|
|
651
|
+
event: dict[str, object] | None,
|
|
652
|
+
default_ref: str | None,
|
|
653
|
+
head: str,
|
|
654
|
+
) -> str:
|
|
655
|
+
selected = next(
|
|
656
|
+
(
|
|
657
|
+
(value, source)
|
|
658
|
+
for value, source in (
|
|
659
|
+
(requested, "explicit"),
|
|
660
|
+
(event_base(event), "trusted_event"),
|
|
661
|
+
(os.environ.get("CCL_SKILL_BASE_REF"), "environment"),
|
|
662
|
+
(default_ref, "default"),
|
|
663
|
+
)
|
|
664
|
+
if value
|
|
665
|
+
),
|
|
666
|
+
None,
|
|
667
|
+
)
|
|
668
|
+
if selected is None:
|
|
669
|
+
fail(
|
|
670
|
+
"candidate base is unresolved; pass --base-ref/CCL_SKILL_BASE_REF, "
|
|
671
|
+
"use a PR event with pull_request.base.sha, or configure --default-base-ref"
|
|
672
|
+
)
|
|
673
|
+
candidate, source = selected
|
|
674
|
+
unresolved_by_source = {
|
|
675
|
+
"explicit": (
|
|
676
|
+
"the explicit candidate base does not resolve; pass a resolvable "
|
|
677
|
+
"--base-ref, or set CCL_SKILL_BASE_REF in a pre-push caller"
|
|
678
|
+
),
|
|
679
|
+
"trusted_event": (
|
|
680
|
+
"the trusted event candidate base does not resolve; repair the event "
|
|
681
|
+
"base or pass an explicit --base-ref"
|
|
682
|
+
),
|
|
683
|
+
"environment": (
|
|
684
|
+
"the CCL_SKILL_BASE_REF candidate base does not resolve; set "
|
|
685
|
+
"CCL_SKILL_BASE_REF to a resolvable landing base"
|
|
686
|
+
),
|
|
687
|
+
"default": (
|
|
688
|
+
"the default candidate base does not resolve; set CCL_SKILL_BASE_REF "
|
|
689
|
+
"to a resolvable landing base"
|
|
690
|
+
),
|
|
691
|
+
}
|
|
692
|
+
base = resolve_commit(
|
|
693
|
+
repo,
|
|
694
|
+
candidate,
|
|
695
|
+
label="configured candidate base",
|
|
696
|
+
unresolved_detail=unresolved_by_source[source],
|
|
697
|
+
)
|
|
698
|
+
merge_base = git_text(
|
|
699
|
+
repo,
|
|
700
|
+
"merge-base",
|
|
701
|
+
base,
|
|
702
|
+
head,
|
|
703
|
+
operation="candidate merge-base",
|
|
704
|
+
)
|
|
705
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", merge_base):
|
|
706
|
+
fail("candidate merge-base returned an invalid object id")
|
|
707
|
+
return merge_base.lower()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def resolve_branch(
|
|
711
|
+
repo: Path, requested: str | None, event: dict[str, object] | None
|
|
712
|
+
) -> str:
|
|
713
|
+
for value in (
|
|
714
|
+
requested,
|
|
715
|
+
event_branch(event),
|
|
716
|
+
os.environ.get("GITHUB_HEAD_REF"),
|
|
717
|
+
os.environ.get("GITHUB_REF_NAME"),
|
|
718
|
+
):
|
|
719
|
+
if value:
|
|
720
|
+
return value
|
|
721
|
+
try:
|
|
722
|
+
result = subprocess.run(
|
|
723
|
+
[
|
|
724
|
+
"git",
|
|
725
|
+
"--no-replace-objects",
|
|
726
|
+
"-C",
|
|
727
|
+
str(repo),
|
|
728
|
+
"symbolic-ref",
|
|
729
|
+
"--quiet",
|
|
730
|
+
"--short",
|
|
731
|
+
"HEAD",
|
|
732
|
+
],
|
|
733
|
+
stdout=subprocess.PIPE,
|
|
734
|
+
stderr=subprocess.DEVNULL,
|
|
735
|
+
check=False,
|
|
736
|
+
text=True,
|
|
737
|
+
timeout=30,
|
|
738
|
+
env=git_environment(),
|
|
739
|
+
)
|
|
740
|
+
except subprocess.TimeoutExpired:
|
|
741
|
+
fail("candidate branch resolution timed out")
|
|
742
|
+
except (OSError, ValueError):
|
|
743
|
+
fail("candidate branch resolution could not run")
|
|
744
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
745
|
+
return result.stdout.strip()
|
|
746
|
+
fail("candidate branch name is unavailable")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def validate_commit_batch(raw: bytes, oids: list[str]) -> None:
|
|
750
|
+
offset = 0
|
|
751
|
+
for expected_oid in oids:
|
|
752
|
+
header_end = raw.find(b"\n", offset)
|
|
753
|
+
if header_end < 0:
|
|
754
|
+
fail("candidate commit object scan returned malformed records")
|
|
755
|
+
header = raw[offset:header_end]
|
|
756
|
+
fields = header.split(b" ")
|
|
757
|
+
if len(fields) != 3:
|
|
758
|
+
fail("candidate commit object scan returned malformed records")
|
|
759
|
+
oid_bytes, object_type, size_bytes = fields
|
|
760
|
+
try:
|
|
761
|
+
oid = oid_bytes.decode("ascii", errors="strict")
|
|
762
|
+
size_text = size_bytes.decode("ascii", errors="strict")
|
|
763
|
+
except UnicodeDecodeError:
|
|
764
|
+
fail("candidate commit object scan returned malformed records")
|
|
765
|
+
if (
|
|
766
|
+
oid.lower() != expected_oid.lower()
|
|
767
|
+
or object_type != b"commit"
|
|
768
|
+
or re.fullmatch(r"0|[1-9][0-9]*", size_text) is None
|
|
769
|
+
):
|
|
770
|
+
fail("candidate commit object scan returned malformed records")
|
|
771
|
+
size = int(size_text)
|
|
772
|
+
payload_start = header_end + 1
|
|
773
|
+
payload_end = payload_start + size
|
|
774
|
+
if payload_end >= len(raw) or raw[payload_end : payload_end + 1] != b"\n":
|
|
775
|
+
fail("candidate commit object scan returned malformed records")
|
|
776
|
+
payload = raw[payload_start:payload_end]
|
|
777
|
+
if b"\x00" in payload:
|
|
778
|
+
fail("candidate commit object contains NUL")
|
|
779
|
+
offset = payload_end + 1
|
|
780
|
+
if offset != len(raw):
|
|
781
|
+
fail("candidate commit object scan returned malformed records")
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def commit_messages(
|
|
785
|
+
repo: Path, base: str, head: str
|
|
786
|
+
) -> list[tuple[str, str, str, str]]:
|
|
787
|
+
oid_output = git(
|
|
788
|
+
repo,
|
|
789
|
+
"rev-list",
|
|
790
|
+
f"{base}..{head}",
|
|
791
|
+
operation="candidate commit enumeration",
|
|
792
|
+
)
|
|
793
|
+
try:
|
|
794
|
+
oids = [line.decode("ascii", errors="strict") for line in oid_output.splitlines()]
|
|
795
|
+
except UnicodeDecodeError:
|
|
796
|
+
fail("candidate commit enumeration returned an invalid object id")
|
|
797
|
+
if any(
|
|
798
|
+
re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", oid) is None
|
|
799
|
+
for oid in oids
|
|
800
|
+
):
|
|
801
|
+
fail("candidate commit enumeration returned an invalid object id")
|
|
802
|
+
if oids:
|
|
803
|
+
raw_objects = git(
|
|
804
|
+
repo,
|
|
805
|
+
"cat-file",
|
|
806
|
+
"--batch",
|
|
807
|
+
input_bytes=b"".join(oid.encode("ascii") + b"\n" for oid in oids),
|
|
808
|
+
operation="candidate commit object scan",
|
|
809
|
+
)
|
|
810
|
+
validate_commit_batch(raw_objects, oids)
|
|
811
|
+
|
|
812
|
+
raw = git(
|
|
813
|
+
repo,
|
|
814
|
+
"log",
|
|
815
|
+
"--encoding=UTF-8",
|
|
816
|
+
"--format=%H%x00%B%x00%an <%ae>%x00%cn <%ce>%x00",
|
|
817
|
+
f"{base}..{head}",
|
|
818
|
+
operation="candidate commit scan",
|
|
819
|
+
)
|
|
820
|
+
parts = raw.split(b"\x00")
|
|
821
|
+
if len(parts) % 4 != 1:
|
|
822
|
+
fail("candidate commit scan returned malformed records")
|
|
823
|
+
records: list[tuple[str, str, str, str]] = []
|
|
824
|
+
record_oids: list[str] = []
|
|
825
|
+
for index in range(0, len(parts) - 3, 4):
|
|
826
|
+
try:
|
|
827
|
+
sha = parts[index].decode("ascii", errors="strict").strip()
|
|
828
|
+
except UnicodeDecodeError:
|
|
829
|
+
fail("candidate commit scan returned an invalid object id")
|
|
830
|
+
locator = sha[:12] if sha else "unknown"
|
|
831
|
+
|
|
832
|
+
decoded: list[str] = []
|
|
833
|
+
for field, value in (
|
|
834
|
+
("message", parts[index + 1]),
|
|
835
|
+
("author", parts[index + 2]),
|
|
836
|
+
("committer", parts[index + 3]),
|
|
837
|
+
):
|
|
838
|
+
try:
|
|
839
|
+
text = value.decode("utf-8", errors="strict")
|
|
840
|
+
except UnicodeDecodeError:
|
|
841
|
+
fail(
|
|
842
|
+
"candidate commit metadata is not valid UTF-8 "
|
|
843
|
+
f"locator={locator} field={field}"
|
|
844
|
+
)
|
|
845
|
+
if "\ufffd" in text:
|
|
846
|
+
fail(
|
|
847
|
+
"candidate commit metadata is not valid UTF-8 "
|
|
848
|
+
f"locator={locator} field={field}"
|
|
849
|
+
)
|
|
850
|
+
decoded.append(text)
|
|
851
|
+
message, author, committer = decoded
|
|
852
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", sha):
|
|
853
|
+
fail("candidate commit scan returned an invalid object id")
|
|
854
|
+
record_oids.append(sha.lower())
|
|
855
|
+
records.append((locator, message, author.strip(), committer.strip()))
|
|
856
|
+
if record_oids != [oid.lower() for oid in oids]:
|
|
857
|
+
fail("candidate commit scan returned inconsistent records")
|
|
858
|
+
return records
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
MAX_TAG_PEEL_DEPTH = 10
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def annotated_tag_records(
|
|
865
|
+
repo: Path, requested: str | None
|
|
866
|
+
) -> list[tuple[str, str, str]]:
|
|
867
|
+
"""Collect (locator, message, tagger) for each annotated-tag layer of the
|
|
868
|
+
candidate ref. resolve_head peels straight to the commit, so tag messages
|
|
869
|
+
and tagger identities — prohibited surfaces of a `refs/tags/*` push — are
|
|
870
|
+
invisible to the commit walk and must be scanned from the tag objects."""
|
|
871
|
+
ref = requested or "HEAD"
|
|
872
|
+
oid = git_text(
|
|
873
|
+
repo,
|
|
874
|
+
"rev-parse",
|
|
875
|
+
"--verify",
|
|
876
|
+
"--quiet",
|
|
877
|
+
"--end-of-options",
|
|
878
|
+
ref,
|
|
879
|
+
operation="candidate object resolution",
|
|
880
|
+
).lower()
|
|
881
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", oid):
|
|
882
|
+
fail("candidate object resolved to an invalid object id")
|
|
883
|
+
records: list[tuple[str, str, str]] = []
|
|
884
|
+
for _ in range(MAX_TAG_PEEL_DEPTH):
|
|
885
|
+
object_type = git_text(
|
|
886
|
+
repo, "cat-file", "-t", oid, operation="candidate tag type scan"
|
|
887
|
+
)
|
|
888
|
+
if object_type != "tag":
|
|
889
|
+
return records
|
|
890
|
+
locator = oid[:12]
|
|
891
|
+
payload = git(
|
|
892
|
+
repo, "cat-file", "tag", oid, operation="candidate tag object scan"
|
|
893
|
+
)
|
|
894
|
+
if b"\x00" in payload:
|
|
895
|
+
fail("candidate tag object contains NUL")
|
|
896
|
+
try:
|
|
897
|
+
text = payload.decode("utf-8", errors="strict")
|
|
898
|
+
except UnicodeDecodeError:
|
|
899
|
+
fail(
|
|
900
|
+
"candidate tag metadata is not valid UTF-8 "
|
|
901
|
+
f"locator={locator}"
|
|
902
|
+
)
|
|
903
|
+
if "�" in text:
|
|
904
|
+
fail(
|
|
905
|
+
"candidate tag metadata is not valid UTF-8 "
|
|
906
|
+
f"locator={locator}"
|
|
907
|
+
)
|
|
908
|
+
header_text, separator, message = text.partition("\n\n")
|
|
909
|
+
if not separator:
|
|
910
|
+
header_text, message = text, ""
|
|
911
|
+
# Git peels the FIRST object header; a crafted literal tag could add a
|
|
912
|
+
# second one to redirect this scan onto a clean decoy chain. Require
|
|
913
|
+
# the canonical layout and reject duplicated core headers outright.
|
|
914
|
+
header_lines = header_text.split("\n")
|
|
915
|
+
if not header_lines or not header_lines[0].startswith("object "):
|
|
916
|
+
fail(f"candidate tag object header is malformed locator={locator}")
|
|
917
|
+
header_counts: dict[str, int] = {}
|
|
918
|
+
target = ""
|
|
919
|
+
tagger = ""
|
|
920
|
+
for line in header_lines:
|
|
921
|
+
key = line.split(" ", 1)[0]
|
|
922
|
+
if key in {"object", "type", "tag", "tagger"}:
|
|
923
|
+
header_counts[key] = header_counts.get(key, 0) + 1
|
|
924
|
+
if header_counts[key] > 1:
|
|
925
|
+
fail(
|
|
926
|
+
"candidate tag object has duplicate headers "
|
|
927
|
+
f"locator={locator}"
|
|
928
|
+
)
|
|
929
|
+
if line.startswith("object "):
|
|
930
|
+
target = line.removeprefix("object ").strip().lower()
|
|
931
|
+
elif line.startswith("tagger "):
|
|
932
|
+
tagger = re.sub(
|
|
933
|
+
r"\s+\d+\s+[+-]\d{4}$", "", line.removeprefix("tagger ")
|
|
934
|
+
).strip()
|
|
935
|
+
records.append((locator, message, tagger))
|
|
936
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", target):
|
|
937
|
+
fail("candidate tag object names an invalid target id")
|
|
938
|
+
oid = target
|
|
939
|
+
fail("candidate tag peel depth exceeded")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def unwrap_inline_markdown_body(body: str) -> str:
|
|
943
|
+
start = 0
|
|
944
|
+
end = len(body)
|
|
945
|
+
while start < end:
|
|
946
|
+
wrapper = next(
|
|
947
|
+
(
|
|
948
|
+
candidate
|
|
949
|
+
for candidate in INLINE_MARKDOWN_WRAPPERS
|
|
950
|
+
if end - start > 2 * len(candidate)
|
|
951
|
+
and body.startswith(candidate, start, end)
|
|
952
|
+
and body.endswith(candidate, start, end)
|
|
953
|
+
),
|
|
954
|
+
None,
|
|
955
|
+
)
|
|
956
|
+
if wrapper is None:
|
|
957
|
+
break
|
|
958
|
+
start += len(wrapper)
|
|
959
|
+
end -= len(wrapper)
|
|
960
|
+
return body[start:end]
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def outer_markdown_link(body: str) -> tuple[str, str] | None:
|
|
964
|
+
if not body.startswith("[") or not body.endswith(")"):
|
|
965
|
+
return None
|
|
966
|
+
label_end = body.find("](", 1)
|
|
967
|
+
if label_end <= 1:
|
|
968
|
+
return None
|
|
969
|
+
label = body[1:label_end]
|
|
970
|
+
if "[" in label or "]" in label:
|
|
971
|
+
return None
|
|
972
|
+
return label, body[label_end + 2 : -1]
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def unwrap_inline_markdown_line(line: str) -> str:
|
|
976
|
+
content_end = len(line.rstrip(" \t"))
|
|
977
|
+
content = line[:content_end]
|
|
978
|
+
trailing = line[content_end:]
|
|
979
|
+
prefix_match = MARKDOWN_LINE_PREFIX_RE.match(content)
|
|
980
|
+
prefix_end = prefix_match.end() if prefix_match is not None else 0
|
|
981
|
+
prefix = content[:prefix_end]
|
|
982
|
+
body = unwrap_inline_markdown_body(content[prefix_end:])
|
|
983
|
+
|
|
984
|
+
link = outer_markdown_link(body)
|
|
985
|
+
if link is None:
|
|
986
|
+
return prefix + body + trailing
|
|
987
|
+
label, destination = link
|
|
988
|
+
label = unwrap_inline_markdown_body(label)
|
|
989
|
+
# Scan the visible label as its own line so anchored trailer rules apply;
|
|
990
|
+
# retain the destination on another line so a session URL cannot disappear.
|
|
991
|
+
return prefix + label + trailing + "\n" + destination
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def normalize_scan_text(text: str) -> str:
|
|
995
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
996
|
+
return "\n".join(
|
|
997
|
+
unwrap_inline_markdown_line(line) for line in normalized.split("\n")
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def violations(surface: str, locator: str, text: str) -> list[str]:
|
|
1002
|
+
scan_text = normalize_scan_text(text)
|
|
1003
|
+
found: list[str] = []
|
|
1004
|
+
patterns = PATTERNS + BRANCH_PATTERNS if surface == "branch" else PATTERNS
|
|
1005
|
+
for pattern in patterns:
|
|
1006
|
+
if pattern.regex.search(scan_text):
|
|
1007
|
+
found.append(
|
|
1008
|
+
f"surface={surface} locator={locator} category={pattern.category}"
|
|
1009
|
+
)
|
|
1010
|
+
return found
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def commit_identity_violations(
|
|
1014
|
+
surface: str, locator: str, identity: str
|
|
1015
|
+
) -> list[str]:
|
|
1016
|
+
if not AI_COMMIT_IDENTITY.fullmatch(identity):
|
|
1017
|
+
return []
|
|
1018
|
+
return [f"surface={surface} locator={locator} category=ai_commit_identity"]
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
class SafeArgumentParser(argparse.ArgumentParser):
|
|
1022
|
+
def error(self, message: str) -> NoReturn:
|
|
1023
|
+
del message
|
|
1024
|
+
self.exit(2, "shared_git_surface_gate_error: invalid arguments\n")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
1028
|
+
parser = SafeArgumentParser(
|
|
1029
|
+
description=(
|
|
1030
|
+
"Scan only the candidate commit range plus current branch and PR text. "
|
|
1031
|
+
"Diagnostics report categories without echoing private identifiers."
|
|
1032
|
+
)
|
|
1033
|
+
)
|
|
1034
|
+
parser.add_argument("--repo", type=Path, default=Path("."))
|
|
1035
|
+
parser.add_argument("--base-ref")
|
|
1036
|
+
parser.add_argument(
|
|
1037
|
+
"--head-ref",
|
|
1038
|
+
help="candidate commit/ref to scan without changing the working tree (default: HEAD)",
|
|
1039
|
+
)
|
|
1040
|
+
parser.add_argument(
|
|
1041
|
+
"--default-base-ref",
|
|
1042
|
+
help="repository-owned fallback used only when no explicit/env/event base exists",
|
|
1043
|
+
)
|
|
1044
|
+
parser.add_argument("--branch-name")
|
|
1045
|
+
parser.add_argument("--event-json", type=Path)
|
|
1046
|
+
parser.add_argument("--pr-text-file", type=Path)
|
|
1047
|
+
return parser.parse_args(argv)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def run(argv: list[str]) -> int:
|
|
1051
|
+
args = parse_args(argv)
|
|
1052
|
+
repo = args.repo
|
|
1053
|
+
if (
|
|
1054
|
+
git_text(
|
|
1055
|
+
repo,
|
|
1056
|
+
"rev-parse",
|
|
1057
|
+
"--is-inside-work-tree",
|
|
1058
|
+
operation="work-tree check",
|
|
1059
|
+
)
|
|
1060
|
+
!= "true"
|
|
1061
|
+
):
|
|
1062
|
+
fail("repository is not a git work tree")
|
|
1063
|
+
reject_git_grafts(repo)
|
|
1064
|
+
event = load_event(event_path(args))
|
|
1065
|
+
head = resolve_head(repo, args.head_ref)
|
|
1066
|
+
base = resolve_base(repo, args.base_ref, event, args.default_base_ref, head)
|
|
1067
|
+
branch = resolve_branch(repo, args.branch_name, event)
|
|
1068
|
+
if base == head and is_branch_push_without_base(event):
|
|
1069
|
+
fail(
|
|
1070
|
+
"direct-push candidate commit range is empty; provide a resolvable "
|
|
1071
|
+
"pre-push base before the candidate head"
|
|
1072
|
+
)
|
|
1073
|
+
commits = commit_messages(repo, base, head)
|
|
1074
|
+
tag_records = annotated_tag_records(repo, args.head_ref)
|
|
1075
|
+
reject_git_grafts(repo)
|
|
1076
|
+
|
|
1077
|
+
findings: list[str] = []
|
|
1078
|
+
findings.extend(violations("branch", "current", branch))
|
|
1079
|
+
for locator, message, tagger in tag_records:
|
|
1080
|
+
findings.extend(violations("tag_message", locator, message))
|
|
1081
|
+
if tagger:
|
|
1082
|
+
findings.extend(
|
|
1083
|
+
commit_identity_violations("tag_tagger", locator, tagger)
|
|
1084
|
+
)
|
|
1085
|
+
for sha, message, author, committer in commits:
|
|
1086
|
+
findings.extend(violations("commit", sha, message))
|
|
1087
|
+
findings.extend(commit_identity_violations("commit_author", sha, author))
|
|
1088
|
+
findings.extend(
|
|
1089
|
+
commit_identity_violations("commit_committer", sha, committer)
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
pr_fields = pull_request_fields(event)
|
|
1093
|
+
for surface, value in pr_fields:
|
|
1094
|
+
findings.extend(violations(surface, "event", value))
|
|
1095
|
+
if args.pr_text_file is not None:
|
|
1096
|
+
proposed = read_regular(
|
|
1097
|
+
args.pr_text_file, max_bytes=MAX_PR_TEXT_BYTES, label="proposed PR text"
|
|
1098
|
+
)
|
|
1099
|
+
if not proposed.strip():
|
|
1100
|
+
fail("proposed PR text must contain non-whitespace content")
|
|
1101
|
+
findings.extend(violations("proposed_pr_text", "file", proposed))
|
|
1102
|
+
|
|
1103
|
+
if findings:
|
|
1104
|
+
print("shared_git_surface_gate: prohibited metadata in candidate surface(s):", file=sys.stderr)
|
|
1105
|
+
for finding in findings:
|
|
1106
|
+
print(f" {finding}", file=sys.stderr)
|
|
1107
|
+
print(
|
|
1108
|
+
"shared_git_surface_gate_failed: use neutral capability/change wording; keep provenance in a private archive",
|
|
1109
|
+
file=sys.stderr,
|
|
1110
|
+
)
|
|
1111
|
+
return 1
|
|
1112
|
+
|
|
1113
|
+
print(
|
|
1114
|
+
"shared_git_surface_gate_ok: "
|
|
1115
|
+
f"commits={len(commits)} branch=1 pr_fields={len(pr_fields)} "
|
|
1116
|
+
f"proposed_pr_files={1 if args.pr_text_file is not None else 0} "
|
|
1117
|
+
f"tag_objects={len(tag_records)}"
|
|
1118
|
+
)
|
|
1119
|
+
return 0
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def emit_error(detail: str) -> int:
|
|
1123
|
+
try:
|
|
1124
|
+
print(f"shared_git_surface_gate_error: {detail}", file=sys.stderr)
|
|
1125
|
+
except (BrokenPipeError, OSError):
|
|
1126
|
+
pass
|
|
1127
|
+
return 2
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def main() -> int:
|
|
1131
|
+
try:
|
|
1132
|
+
return run(sys.argv[1:])
|
|
1133
|
+
except GateError as exc:
|
|
1134
|
+
return emit_error(str(exc))
|
|
1135
|
+
except BrokenPipeError:
|
|
1136
|
+
return 2
|
|
1137
|
+
except Exception:
|
|
1138
|
+
return emit_error("unexpected internal failure")
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
if __name__ == "__main__":
|
|
1142
|
+
raise SystemExit(main())
|