Files
2026-07-15 00:21:02 -04:00

247 lines
8.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# lint-codex-native.sh — Lint skills-codex/ for Codex-native compliance
#
# Checks:
# 1. No slash-command invocations (must use $ prefix)
# 2. No Claude Code primitives in main execution flow (before ## References)
# 3. No ~/.claude/ paths (must use ~/.codex/)
# 4. No "Claude Code" runtime references (use "Codex" or runtime-neutral)
# 5. Required: Portability Appendix if Claude primitives exist in main flow
#
# Usage:
# scripts/lint-codex-native.sh [--strict] [--skill <name>]
#
# Exit codes:
# 0 — all checks pass
# 1 — violations found
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILLS_DIR="$REPO_ROOT/skills-codex"
# Cross-runtime skills legitimately document non-Codex runtimes (cass parses
# Claude Code logs, cc-hooks is about Claude Code hooks). Shared exemption list
# with codex-sync and the other Codex gates.
CROSS_RUNTIME_FILE="$REPO_ROOT/scripts/lint/codex-cross-runtime-skills.txt"
is_cross_runtime() {
[[ -f "$CROSS_RUNTIME_FILE" ]] || return 1
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$CROSS_RUNTIME_FILE" | grep -qxF "$1"
}
# parity_only twins are GENERATED by codex-sync and verified by its byte-exact
# drift gate (codex-sync --check) — re-checking content rules on a generated
# artifact is the whack-a-mole this fix removes. Content lint runs on BESPOKE
# (hand-authored) twins only. (codex-sync runs in regen-all ahead of this gate.)
BESPOKE_SKILLS="$(python3 -c "import json; d=json.load(open('$REPO_ROOT/skills-codex-overrides/catalog.json')); print(chr(10).join(e['name'] for e in d.get('skills',[]) if e.get('treatment')=='bespoke'))" 2>/dev/null || true)"
is_bespoke() { grep -qxF "$1" <<<"$BESPOKE_SKILLS"; }
STRICT=false
FILTER_SKILL=""
ERRORS=0
WARNINGS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--strict) STRICT=true; shift ;;
--skill) FILTER_SKILL="$2"; shift 2 ;;
*) echo "Unknown flag: $1"; exit 1 ;;
esac
done
# Colors
RED='\033[0;31m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
NC='\033[0m'
error() {
echo -e "${RED} FAIL${NC}: $1"
ERRORS=$((ERRORS + 1))
}
warn() {
echo -e "${YELLOW} WARN${NC}: $1"
WARNINGS=$((WARNINGS + 1))
}
pass() {
if $STRICT; then
echo -e "${GREEN} PASS${NC}: $1"
fi
}
# Current source metadata owns the slash-invocation vocabulary.
SKILL_NAMES="$(find "$REPO_ROOT/skills" -mindepth 2 -maxdepth 2 -name SKILL.md -print | sed 's#/SKILL.md$##; s#^.*/##' | LC_ALL=C sort | paste -sd '|' -)"
# Claude-only primitives (should not appear in main execution flow)
CLAUDE_PRIMITIVES="TeamCreate|SendMessage|EnterPlanMode|ExitPlanMode|EnterWorktree"
# Find the line number where a section starts (0 if not found)
find_section_line() {
local file="$1"
local pattern="$2"
local line
line=$(grep -n "$pattern" "$file" | head -1 | cut -d: -f1)
echo "${line:-0}"
}
# Count matches in a string (handling empty input)
count_lines() {
local input="$1"
if [[ -z "$input" ]]; then
echo 0
else
echo "$input" | wc -l | tr -d ' '
fi
}
# Check a single skill
check_skill() {
local skill_name="$1"
local skill_file="$SKILLS_DIR/$skill_name/SKILL.md"
# parity_only twins are generator-verified (codex-sync drift gate); only lint
# hand-authored bespoke twins. An explicit --skill request is always honored.
if [[ -z "$FILTER_SKILL" ]] && ! is_bespoke "$skill_name"; then
return
fi
if [[ ! -f "$skill_file" ]]; then
warn "$skill_name: SKILL.md not found"
return
fi
local refs_line
refs_line=$(find_section_line "$skill_file" '^## Reference')
local port_line
port_line=$(find_section_line "$skill_file" '^## Portability')
local total_lines
total_lines=$(wc -l < "$skill_file" | tr -d ' ')
# Determine the "main flow" boundary (before References or Portability)
local main_end=$total_lines
if [[ $refs_line -gt 0 ]]; then
main_end=$refs_line
fi
if [[ $port_line -gt 0 && $port_line -lt $main_end ]]; then
main_end=$port_line
fi
# --- Check 1: Slash-command invocations ---
# Use perl lookbehind for accurate detection (avoids false positives from file paths)
# Real slash-commands: ` /research`, `"/council`, backtick-/skill
# False positives: `.agents/council/`, `skills/research/`, `merge/release`
local slash_hits
slash_hits=$(perl -ne "print \"$.: \$_\" if m{(?<![A-Za-z0-9_/.=\\\$-])/(${SKILL_NAMES})(?![A-Za-z0-9-])}" "$skill_file" 2>/dev/null || true)
if [[ -n "$slash_hits" ]]; then
local count
count=$(count_lines "$slash_hits")
error "$skill_name: $count slash-command invocation(s) — must use \$ prefix"
if $STRICT; then
echo "$slash_hits" | head -5 | sed 's/^/ /'
fi
else
pass "$skill_name: no slash-command invocations"
fi
# --- Check 2: Claude primitives in main execution flow ---
if [[ $main_end -gt 1 ]]; then
local prim_hits
prim_hits=$(head -n "$main_end" "$skill_file" | grep -En "(${CLAUDE_PRIMITIVES})" 2>/dev/null || true)
if [[ -n "$prim_hits" ]]; then
local count
count=$(count_lines "$prim_hits")
error "$skill_name: $count Claude primitive(s) in main execution flow (before line $main_end)"
if $STRICT; then
echo "$prim_hits" | head -5 | sed 's/^/ /'
fi
else
pass "$skill_name: no Claude primitives in main flow"
fi
fi
# --- Check 3: ~/.claude/ paths ---
# Cross-runtime skills may reference ~/.claude accurately (cc-hooks documents
# the Claude Code hook config path); skip this check for them.
if is_cross_runtime "$skill_name"; then
pass "$skill_name: cross-runtime skill — ~/.claude/ check skipped"
return
fi
local path_hits
# The tilde here is a literal grep PATTERN (matching the string "~/.claude/"
# in skill files), not a path meant to expand — SC2088 is a false positive.
# shellcheck disable=SC2088
path_hits=$(grep -n '~/\.claude/' "$skill_file" | grep -v 'Portability\|non-Codex\|appendix' || true)
if [[ -n "$path_hits" ]]; then
local count
count=$(count_lines "$path_hits")
if $STRICT; then
error "$skill_name: $count ~/.claude/ path reference(s) — use ~/.codex/"
else
warn "$skill_name: $count ~/.claude/ path reference(s)"
fi
else
pass "$skill_name: no ~/.claude/ paths"
fi
# --- Check 4: "Claude Code" runtime reference ---
local runtime_hits
runtime_hits=$(grep -in 'Claude Code' "$skill_file" | grep -vi 'Portability\|non-Codex\|appendix\|backend-claude\|claude-code-latest' || true)
if [[ -n "$runtime_hits" ]]; then
local count
count=$(count_lines "$runtime_hits")
warn "$skill_name: $count 'Claude Code' runtime reference(s)"
else
pass "$skill_name: no 'Claude Code' runtime references"
fi
# --- Check 5: Claude primitives anywhere + no Portability Appendix ---
local total_prims
total_prims=$(grep -cE "(${CLAUDE_PRIMITIVES})" "$skill_file" 2>/dev/null) || total_prims=0
if [[ "$total_prims" -gt 0 && "$port_line" -eq 0 ]]; then
if [[ "$refs_line" -gt 0 ]]; then
local main_prims
main_prims=$(head -n "$refs_line" "$skill_file" | grep -cE "(${CLAUDE_PRIMITIVES})" 2>/dev/null) || main_prims=0
if [[ "$main_prims" -gt 0 ]]; then
warn "$skill_name: $total_prims Claude primitive(s) total ($main_prims in main flow) — needs Portability Appendix"
fi
else
warn "$skill_name: $total_prims Claude primitive(s) but no Portability Appendix or References section"
fi
fi
}
echo "Codex-Native Skill Lint"
echo "======================="
echo "Directory: $SKILLS_DIR"
echo ""
if [[ -n "$FILTER_SKILL" ]]; then
echo "Checking: $FILTER_SKILL"
echo ""
check_skill "$FILTER_SKILL"
else
for skill_dir in "$SKILLS_DIR"/*/; do
skill_name=$(basename "$skill_dir")
check_skill "$skill_name"
done
fi
echo ""
echo "======================="
echo "Errors: $ERRORS | Warnings: $WARNINGS"
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}FAIL${NC}: $ERRORS error(s) found"
exit 1
elif [[ $WARNINGS -gt 0 ]]; then
echo -e "${YELLOW}WARN${NC}: $WARNINGS warning(s) (pass with warnings)"
exit 0
else
echo -e "${GREEN}PASS${NC}: all checks clean"
exit 0
fi