check-conventions.sh 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. #!/bin/sh
  2. #
  3. # ArozOS contribution convention checker
  4. # =====================================
  5. #
  6. # Enforces the contribution rules documented in CLAUDE.md against *new* code.
  7. # It is intentionally written in portable POSIX sh with only the standard
  8. # git/grep tooling so it runs the same way on a contributor's machine, inside
  9. # the Claude Code PostToolUse hook and in CI (rule 5: no system dependencies).
  10. #
  11. # Usage:
  12. # scripts/check-conventions.sh <file> [<file> ...] Check specific files
  13. # scripts/check-conventions.sh --diff <base-ref> Check files changed vs base-ref
  14. # scripts/check-conventions.sh --hook Read a Claude Code hook
  15. # payload (JSON) from stdin
  16. #
  17. # Exit status:
  18. # 0 no ERROR-level violations (WARN findings may still be printed)
  19. # 1 at least one ERROR-level violation was found (CI / direct invocation)
  20. # 2 findings in --hook mode (surfaces the report back to Claude)
  21. #
  22. # Escape hatch:
  23. # Append the marker arozos-lint-ignore to a source line to skip the
  24. # line-level checks (raw logger / hardcoded path) for that single line.
  25. # Use it only with a short justification comment.
  26. set -u
  27. errors=0
  28. warns=0
  29. err() { printf ' [ERROR] %s\n' "$1" >&2; errors=$((errors + 1)); }
  30. warn() { printf ' [WARN] %s\n' "$1" >&2; warns=$((warns + 1)); }
  31. repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
  32. # emoji_bytes is the UTF-8 lead-byte pair (0xF0 0x9F) shared by the pictographic
  33. # emoji planes U+1F000–U+1FFFF (faces, objects, symbols, regional-indicator
  34. # flags) — i.e. the colourful emoji people actually paste. Matching just these
  35. # two bytes keeps the check byte-oriented and free of false positives on smart
  36. # quotes / em-dashes (which live in the 0xE2 range), and portable across
  37. # BusyBox/BSD/GNU grep (rule 5: no system dependencies).
  38. emoji_bytes=$(printf '\360\237')
  39. # is_platform_file returns success when a Go file is scoped to a single OS/arch
  40. # by its filename suffix (e.g. foo_linux.go, bar_windows_amd64.go). Such files
  41. # are the project's sanctioned home for platform-specific code, so the
  42. # portability checks do not apply to them.
  43. is_platform_file() {
  44. printf '%s' "$1" | grep -Eq \
  45. '_(linux|windows|darwin|freebsd|openbsd|netbsd|dragonfly|solaris|illumos|aix|android|js|wasm|plan9)(_[a-z0-9]+)?\.go$'
  46. }
  47. has_build_constraint() {
  48. [ -f "$1" ] && grep -Eq '^//go:build|^// \+build' "$1"
  49. }
  50. # check_lines applies the per-line rules to a stream of source lines supplied on
  51. # stdin. $1 is the file the lines belong to (used for context + exemptions).
  52. check_lines() {
  53. file=$1
  54. # Skip the marker so opted-out lines are not re-flagged.
  55. scan=$(grep -v 'arozos-lint-ignore' || true)
  56. # --- Rule 1: managed logger, not the standard log package -------------
  57. # The logger package itself legitimately wraps "log"; everything else must
  58. # route through logger.PrintAndLog so output lands in the system log.
  59. case "$file" in
  60. *mod/info/logger/*) ;;
  61. *)
  62. hits=$(printf '%s\n' "$scan" |
  63. grep -E 'log\.(Print|Printf|Println|Fatal|Fatalf|Fatalln|Panic|Panicf|Panicln)\(' || true)
  64. if [ -n "$hits" ]; then
  65. err "$file: uses the standard \"log\" package. New code must call logger.PrintAndLog(title, message, err) instead (rule 1)."
  66. fi
  67. ;;
  68. esac
  69. # --- Rule 5: portability, no hardcoded OS paths -----------------------
  70. if ! is_platform_file "$file"; then
  71. paths=$(printf '%s\n' "$scan" |
  72. grep -E '"(/usr/|/etc/|/var/|/bin/|/sbin/|/opt/|/root/|/home/)|"[A-Za-z]:\\\\|"[A-Za-z]:/' || true)
  73. if [ -n "$paths" ]; then
  74. err "$file: contains a hardcoded OS path literal. Build paths with filepath.Join and os.TempDir/UserHomeDir so ArozOS stays cross-platform (rule 5)."
  75. fi
  76. fi
  77. # --- Rule 4: new HTTP endpoints need a deliberate security decision ---
  78. endpoints=$(printf '%s\n' "$scan" | grep -E 'http\.HandleFunc\(' || true)
  79. if [ -n "$endpoints" ]; then
  80. case "$file" in
  81. *mod/prouter/*) ;; # the permission router wraps http.HandleFunc by design
  82. *)
  83. warn "$file: registers an endpoint with raw http.HandleFunc. Prefer prout.NewModuleRouter(...).HandleFunc for auth/permission, or confirm the endpoint is intentionally public (rule 4)."
  84. ;;
  85. esac
  86. fi
  87. }
  88. # check_emoji flags literal Unicode emoji in a stream of source lines on stdin.
  89. # $1 is the file the lines belong to. Emoji are never allowed in the program
  90. # (rule 6): draw an inline SVG or use one of the project's icon libraries
  91. # instead. Applies to both Go and front-end (HTML/JS/CSS) sources.
  92. check_emoji() {
  93. file=$1
  94. scan=$(grep -v 'arozos-lint-ignore' || true)
  95. hits=$(printf '%s\n' "$scan" | LC_ALL=C grep -c "$emoji_bytes" 2>/dev/null || true)
  96. if [ -n "$hits" ] && [ "$hits" != "0" ]; then
  97. err "$file: contains a literal Unicode emoji. Emoji are not allowed — draw an inline SVG or use a project icon library (Semantic UI <i class=\"... icon\"> first, else a local SVG); see \"Icon and emoji policy\" in CLAUDE.md (rule 6)."
  98. fi
  99. }
  100. # check_file applies the file-level rules to a single Go source file path.
  101. check_file() {
  102. file=$1
  103. case "$file" in
  104. *_test.go) return ;; # test files are not themselves subject to these rules
  105. esac
  106. # --- Rule 5 (soft): isolate platform calls in build-tagged files -----
  107. if ! is_platform_file "$file" && ! has_build_constraint "$file"; then
  108. if grep -Eq 'exec\.Command\(|(^|[^.])syscall\.' "$file" 2>/dev/null; then
  109. warn "$file: calls exec.Command/syscall in a cross-platform file. Move OS-specific code into a *_linux.go / *_windows.go / *_darwin.go file or guard it with a //go:build tag (rule 5)."
  110. fi
  111. fi
  112. # --- Rule 2: every package ships tests -------------------------------
  113. case "$file" in
  114. *mod/*)
  115. dir=$(dirname "$file")
  116. if ! ls "$dir"/*_test.go >/dev/null 2>&1; then
  117. warn "$file: package $dir has no *_test.go file. New functions must ship with tests (rule 2)."
  118. fi
  119. ;;
  120. esac
  121. }
  122. # license_reminder fires once when dependency manifests change.
  123. license_reminder() {
  124. warn "go.mod/go.sum changed: confirm every new dependency is MIT, BSD, Apache-2.0, MPL-2.0 or ISC (GPL-compatible and OK for commercial use). Reject GPL/AGPL/unknown-licensed modules (rule 3)."
  125. }
  126. scan_one() {
  127. file=$1
  128. case "$file" in
  129. go.mod | go.sum | */go.mod | */go.sum)
  130. license_reminder
  131. return
  132. ;;
  133. *.go)
  134. # Single-file / hook mode scans the whole file content.
  135. check_lines "$file" <"$file"
  136. check_emoji "$file" <"$file"
  137. check_file "$file"
  138. ;;
  139. *.html | *.htm | *.js | *.mjs | *.css)
  140. # Front-end sources: emoji policy only (rule 6).
  141. check_emoji "$file" <"$file"
  142. ;;
  143. *) return ;;
  144. esac
  145. }
  146. mode=${1:---help}
  147. case "$mode" in
  148. --hook)
  149. # Extract tool_input.file_path from the hook JSON payload on stdin without
  150. # requiring jq (rule 5: no extra system dependencies).
  151. payload=$(cat)
  152. file=$(printf '%s' "$payload" |
  153. sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
  154. [ -z "$file" ] && exit 0
  155. scan_one "$file"
  156. if [ "$errors" -gt 0 ] || [ "$warns" -gt 0 ]; then
  157. printf '\nArozOS convention check: %d error(s), %d warning(s). See CLAUDE.md.\n' \
  158. "$errors" "$warns" >&2
  159. exit 2
  160. fi
  161. exit 0
  162. ;;
  163. --diff)
  164. base=${2:-}
  165. if [ -z "$base" ]; then
  166. echo "usage: $0 --diff <base-ref>" >&2
  167. exit 1
  168. fi
  169. cd "$repo_root" || exit 1
  170. changed=$(git diff --name-only --diff-filter=ACM "$base" -- '*.go' 'go.mod' 'go.sum' '**/go.mod' '**/go.sum' '*.html' '*.htm' '*.js' '*.mjs' '*.css')
  171. [ -z "$changed" ] && {
  172. echo "No Go/module changes to check." >&2
  173. exit 0
  174. }
  175. # Iterate over a temp file rather than a pipe so the err/warn counters,
  176. # which live in this shell, survive (a piped while-loop runs in a subshell).
  177. tmp=$(mktemp)
  178. added=$(mktemp)
  179. printf '%s\n' "$changed" >"$tmp"
  180. while IFS= read -r f; do
  181. [ -n "$f" ] || continue
  182. emoji_only=""
  183. case "$f" in
  184. go.mod | go.sum | */go.mod | */go.sum)
  185. license_reminder
  186. continue
  187. ;;
  188. *.go) ;;
  189. *.html | *.htm | *.js | *.mjs | *.css) emoji_only=1 ;;
  190. *) continue ;;
  191. esac
  192. printf 'Checking %s\n' "$f" >&2
  193. # Diff mode only scans *added* lines for the per-line rules. Feed them
  194. # via redirection (not a pipe) so the checks run in this shell.
  195. git diff -U0 "$base" -- "$f" | grep -E '^\+' | grep -Ev '^\+\+\+' | sed 's/^+//' >"$added"
  196. check_emoji "$f" <"$added"
  197. if [ -z "$emoji_only" ]; then
  198. check_lines "$f" <"$added"
  199. check_file "$f"
  200. fi
  201. done <"$tmp"
  202. rm -f "$tmp" "$added"
  203. ;;
  204. --help | -h)
  205. sed -n '2,40p' "$0"
  206. exit 0
  207. ;;
  208. *)
  209. # Treat all arguments as explicit file paths.
  210. for f in "$@"; do
  211. scan_one "$f"
  212. done
  213. ;;
  214. esac
  215. printf '\nArozOS convention check: %d error(s), %d warning(s).\n' "$errors" "$warns" >&2
  216. [ "$errors" -gt 0 ] && exit 1
  217. exit 0