@ccoalm/ccl-skills 0.6.2 → 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.
Files changed (139) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +11 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-unverified-cli-flag.sh +309 -0
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_unverified_cli_flag.sh +483 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +5 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +10 -8
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +1 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/SKILL.md +1 -1
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +2 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-platform-architecture.md +1 -1
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/event-driven-architecture.md +14 -11
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +2 -2
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +5 -1
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +13 -11
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +72 -0
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +1 -1
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +25 -9
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/source-register.md +1 -0
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +1 -1
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +16 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/secret-and-config-management.md +7 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +11 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -10
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +4 -5
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
  56. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
  57. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
  58. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
  59. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
  60. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
  61. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +1 -1
  62. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
  63. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
  64. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
  65. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +7 -1
  66. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +8 -5
  67. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
  68. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
  69. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/SKILL.md +5 -1
  70. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +1 -1
  71. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/audit-history-architecture.md +31 -0
  72. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-platform-architecture.md +1 -1
  73. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/event-driven-architecture.md +7 -4
  74. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +2 -2
  75. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/notification-architecture.md +28 -0
  76. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +1 -1
  77. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/replay-comparison-architecture.md +28 -0
  78. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/workflow-state-architecture.md +39 -0
  79. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +10 -7
  80. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/ai-service-wiring-patterns.md +8 -0
  81. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/audit-history-patterns.md +29 -0
  82. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/background-job-patterns.md +16 -0
  83. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/batch-and-artifact-patterns.md +25 -1
  84. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/notification-patterns.md +40 -0
  85. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/public-api-security-patterns.md +1 -1
  86. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/replay-comparison-patterns.md +30 -0
  87. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/state-machine-task-patterns.md +48 -0
  88. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/testing-and-quality-patterns.md +10 -1
  89. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +2 -0
  90. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
  91. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +45 -0
  92. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +142 -4
  93. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +21 -2
  94. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
  95. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
  96. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/parallel-stack-references-pattern.md +5 -4
  97. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
  98. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +69 -0
  99. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +10 -0
  100. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
  101. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
  102. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +93 -2
  103. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-parallel-stack-parity.sh +119 -0
  104. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
  105. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
  106. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
  107. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
  108. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
  109. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_parallel_stack_parity.sh +183 -0
  110. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +19 -0
  111. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
  112. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
  113. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
  114. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
  115. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
  116. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
  117. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
  118. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
  119. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
  120. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
  121. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
  122. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
  123. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
  124. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
  125. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
  126. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
  127. package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +9 -6
  128. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +11 -11
  129. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
  130. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/fitness-functions.md +16 -0
  131. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/scenario-testing.md +1 -1
  132. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +16 -5
  133. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +5 -3
  134. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/delivery-face-closeout.md +16 -6
  135. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/self-benchmark-baseline.md +37 -0
  136. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +7 -5
  137. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
  138. package/dist/assets/release.json +275 -105
  139. 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())