Skip to content
This repository was archived by the owner on Apr 5, 2026. It is now read-only.

Commit 532c482

Browse files
HerbHallclaude
andauthored
ci: add rule metadata validation job (#136)
* ci: add rule metadata validation job to lint workflow Add validate-rule-metadata job that checks Tier 2 rules files for: - **Added:** line format (date, source, valid status) - **See also:** cross-references point to existing entries - Frontmatter entry_count matches actual active entry count Only validates entries that have metadata annotations. Unannotated entries are skipped. Uses GitHub Actions error annotations for precise line-level feedback. Closes #130 Co-Authored-By: Claude <noreply@anthropic.com> * fix: strip CRLF from rules files before metadata validation Files committed from Windows have \r\n line endings. The grep extractions captured trailing \r, causing status comparison failures and arithmetic syntax errors in entry_count validation. Pre-process each file through tr -d '\r' to a temp file for consistent parsing on Ubuntu CI runners. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f585e11 commit 532c482

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,133 @@ jobs:
7070
echo "::warning::Skills in claude/skills/ not listed in verify.sh: $UNLISTED"
7171
fi
7272
echo "OK: verify.sh skill list is accurate"
73+
74+
validate-rule-metadata:
75+
name: Validate rule metadata
76+
runs-on: ubuntu-latest
77+
steps:
78+
- uses: actions/checkout@v4
79+
- name: Validate lifecycle metadata in Tier 2 rules
80+
run: |
81+
errors=0
82+
83+
# Map of files to their entry prefix
84+
declare -A FILE_PREFIX=(
85+
["claude/rules/autolearn-patterns.md"]="AP"
86+
["claude/rules/known-gotchas.md"]="KG"
87+
)
88+
89+
# Collect all entry headings across both files for cross-reference validation
90+
# Format: PREFIX#NUMBER for each ## N. heading found
91+
declare -A ALL_ENTRIES
92+
for file in "${!FILE_PREFIX[@]}"; do
93+
prefix="${FILE_PREFIX[$file]}"
94+
while IFS=: read -r num rest; do
95+
ALL_ENTRIES["${prefix}#${num}"]=1
96+
done < <(tr -d '\r' < "$file" | grep -P '^## (\d+)\.' | grep -oP '(?<=## )\d+')
97+
done
98+
99+
for file in "${!FILE_PREFIX[@]}"; do
100+
prefix="${FILE_PREFIX[$file]}"
101+
echo "--- Validating $file (prefix: $prefix) ---"
102+
103+
# Strip Windows carriage returns for consistent parsing
104+
# (files may be committed from Windows with CRLF endings)
105+
clean_file=$(mktemp)
106+
tr -d '\r' < "$file" > "$clean_file"
107+
108+
# -------------------------------------------------------
109+
# (a) Validate **Added:** line format
110+
# -------------------------------------------------------
111+
while IFS=: read -r lineno line; do
112+
# Expected: **Added:** YYYY-MM-DD | **Source:** <word> | **Status:** <status>
113+
if ! echo "$line" | grep -qP '^\*\*Added:\*\* \d{4}-\d{2}-\d{2} \| \*\*Source:\*\* \S+ \| \*\*Status:\*\* .+$'; then
114+
echo "::error file=$file,line=$lineno::Malformed **Added:** line. Expected: **Added:** YYYY-MM-DD | **Source:** <word> | **Status:** <status>"
115+
errors=$((errors + 1))
116+
continue
117+
fi
118+
119+
# Extract and validate date
120+
date_val=$(echo "$line" | grep -oP '(?<=\*\*Added:\*\* )\d{4}-\d{2}-\d{2}')
121+
if ! date -d "$date_val" >/dev/null 2>&1; then
122+
echo "::error file=$file,line=$lineno::Invalid date '$date_val' in **Added:** line"
123+
errors=$((errors + 1))
124+
fi
125+
126+
# Extract and validate status
127+
status_val=$(echo "$line" | grep -oP '(?<=\*\*Status:\*\* ).+$')
128+
if [ "$status_val" = "active" ]; then
129+
: # valid
130+
elif echo "$status_val" | grep -qP '^deprecated \(\d{4}-\d{2}-\d{2}\) -- .+$'; then
131+
: # valid deprecated format
132+
elif echo "$status_val" | grep -qP '^superseded-by-(AP|KG)#\d+$'; then
133+
: # valid superseded format -- note: uses PREFIX#N with hash
134+
elif echo "$status_val" | grep -qP '^superseded-by-(AP|KG)\d+$'; then
135+
: # also accept without hash for backward compat (superseded-by-KG17)
136+
else
137+
echo "::error file=$file,line=$lineno::Invalid status '$status_val'. Must be: active | deprecated (YYYY-MM-DD) -- reason | superseded-by-<PREFIX>#<N>"
138+
errors=$((errors + 1))
139+
fi
140+
done < <(grep -nP '^\*\*Added:\*\*' "$clean_file")
141+
142+
# -------------------------------------------------------
143+
# (b) Validate **See also:** references
144+
# -------------------------------------------------------
145+
while IFS=: read -r lineno line; do
146+
# Extract all AP#N and KG#N references
147+
refs=$(echo "$line" | grep -oP '(AP|KG)#\d+' || true)
148+
for ref in $refs; do
149+
ref_prefix=$(echo "$ref" | grep -oP '^(AP|KG)')
150+
ref_num=$(echo "$ref" | grep -oP '\d+$')
151+
152+
# Determine which file to check
153+
if [ "$ref_prefix" = "AP" ]; then
154+
target_file="claude/rules/autolearn-patterns.md"
155+
else
156+
target_file="claude/rules/known-gotchas.md"
157+
fi
158+
159+
# Verify the heading ## N. exists in the target file
160+
# (use clean_file for the current file, read target fresh with tr)
161+
if ! tr -d '\r' < "$target_file" | grep -qP "^## ${ref_num}\."; then
162+
echo "::error file=$file,line=$lineno::Reference $ref points to non-existent entry (## ${ref_num}. not found in $target_file)"
163+
errors=$((errors + 1))
164+
fi
165+
done
166+
done < <(grep -nP '^\*\*See also:\*\*' "$clean_file")
167+
168+
# -------------------------------------------------------
169+
# (c) Validate entry_count in frontmatter
170+
# -------------------------------------------------------
171+
# Read entry_count from YAML frontmatter
172+
frontmatter_count=$(grep -P '^entry_count:\s*\d+' "$clean_file" | grep -oP '\d+' || echo "")
173+
if [ -z "$frontmatter_count" ]; then
174+
echo "::error file=$file,line=1::Missing entry_count in YAML frontmatter"
175+
errors=$((errors + 1))
176+
else
177+
# Count all ## N. headings
178+
total_entries=$(grep -cP '^## \d+\.' "$clean_file" || echo "0")
179+
180+
# Count deprecated and superseded entries
181+
inactive_entries=$(grep -cP '^\*\*Status:\*\* (deprecated|superseded)' "$clean_file" || echo "0")
182+
183+
# Active = total - inactive
184+
active_entries=$((total_entries - inactive_entries))
185+
186+
if [ "$active_entries" != "$frontmatter_count" ]; then
187+
# Find the line number of entry_count for the annotation
188+
ec_line=$(grep -nP '^entry_count:' "$clean_file" | head -1 | cut -d: -f1)
189+
echo "::error file=$file,line=$ec_line::entry_count is $frontmatter_count but computed active entries = $active_entries (total=$total_entries, inactive=$inactive_entries)"
190+
errors=$((errors + 1))
191+
fi
192+
fi
193+
194+
rm -f "$clean_file"
195+
echo "--- Done: $file ---"
196+
done
197+
198+
if [ "$errors" -gt 0 ]; then
199+
echo "Found $errors metadata validation error(s)"
200+
exit 1
201+
fi
202+
echo "All rule metadata is valid"

0 commit comments

Comments
 (0)