create-afe-project 0.0.0-stage → 1.0.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/CHANGELOG.md +351 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/create-afe-project.js +123 -0
- package/package.json +43 -4
- package/src/answers.js +147 -0
- package/src/config.js +152 -0
- package/src/project.js +217 -0
- package/src/render.js +51 -0
- package/template/.claude/harness.conf +47 -0
- package/template/.claude/hooks/post-edit-check.sh +38 -0
- package/template/.claude/hooks/protect-docs.sh +315 -0
- package/template/.claude/hooks/stop-check-decisions.sh +25 -0
- package/template/.claude/hooks/stop-check-docs.sh +29 -0
- package/template/.claude/hooks/stop-check-template.sh +23 -0
- package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
- package/template/.claude/path-classes +61 -0
- package/template/.claude/settings.project.json +5 -0
- package/template/.claude/settings.template.json +76 -0
- package/template/.claude/skills/afe-init/SKILL.md +104 -0
- package/template/.claude/skills/create-pr/SKILL.md.jinja +107 -0
- package/template/.claude/skills/update-template/SKILL.md +40 -0
- package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +46 -0
- package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +56 -0
- package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
- package/template/.github/workflows/decisions.yml.jinja +47 -0
- package/template/.github/workflows/docs.yml.jinja +40 -0
- package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +51 -0
- package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +49 -0
- package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +40 -0
- package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
- package/template/.markdownlint-cli2.jsonc +27 -0
- package/template/.template/owned.txt.jinja +63 -0
- package/template/AGENTS.md.jinja +91 -0
- package/template/CLAUDE.md +3 -0
- package/template/README.md.jinja +41 -0
- package/template/_gitignore +14 -0
- package/template/docs/README.md.jinja +44 -0
- package/template/docs/contributing.md.jinja +206 -0
- package/template/docs/decisions/adr-template.md +38 -0
- package/template/docs/documentation-rules.md.jinja +200 -0
- package/template/docs/testing-rules.md +80 -0
- package/template/scripts/check-decisions.sh +382 -0
- package/template/scripts/check-docs.sh +20 -0
- package/template/scripts/check-links.mjs +142 -0
- package/template/scripts/check-project.sh +27 -0
- package/template/scripts/check-template.sh +88 -0
- package/template/scripts/check.sh +21 -0
- package/template/scripts/env.sh +29 -0
- package/template/scripts/gen-decisions-index.sh +57 -0
- package/template/scripts/gen-settings.sh +14 -0
- package/template/scripts/lib/classes.sh +89 -0
- package/template/scripts/lib/git-base.sh +23 -0
- package/template/scripts/lib/harness-conf.sh +48 -0
- package/template/scripts/lib/json.mjs +160 -0
- package/template/scripts/pr-metrics.sh +80 -0
- package/template/scripts/protect-branch.sh +53 -0
- package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
- package/template/scripts/{% if fail_without_change %}fail-without-change.sh{% endif %} +88 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Decision-record and merge-readiness checks (docs/documentation-rules.md, sections 5, 7, and 8),
|
|
3
|
+
# run by scripts/check.sh. The Stop hook .claude/hooks/stop-check-decisions.sh runs the integrity
|
|
4
|
+
# checks; the `decisions` workflow (.github/workflows/decisions.yml) runs both kinds on every pull
|
|
5
|
+
# request. The integrity checks also require `scope`, `summary`, and the next free number in new
|
|
6
|
+
# records, and a fresh index. Merge readiness reads the pull request description: the labels of
|
|
7
|
+
# its open items, "Acceptance and test plan", "Owner decisions" for every marker the change adds,
|
|
8
|
+
# "Gate files" for every gate path it touches, and the "Metrics" tables (docs/contributing.md,
|
|
9
|
+
# section 3); with the acceptance_check module it runs scripts/check-acceptance.sh too.
|
|
10
|
+
#
|
|
11
|
+
# Usage: scripts/check-decisions.sh [--base <ref>] [--ready [--pr-body <file>] | --classes]
|
|
12
|
+
# --base the branch the change goes into; by default scripts/lib/git-base.sh chooses it.
|
|
13
|
+
# The change is everything between the merge base and the working tree, untracked
|
|
14
|
+
# files included. An unknown ref is an error.
|
|
15
|
+
# --ready run the merge-readiness checks instead of the integrity checks.
|
|
16
|
+
# --pr-body the pull request description to check (docs/contributing.md, section 3).
|
|
17
|
+
# --classes print "<class><TAB><path>" for every path the change touches, then the review it
|
|
18
|
+
# needs before the pull request: "review<TAB>none|one|santa" (documentation-rules.md,
|
|
19
|
+
# section 8), and nothing else.
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
cd "$(dirname "$0")/.."
|
|
22
|
+
|
|
23
|
+
base= ready=0 body= classes=0
|
|
24
|
+
while [ $# -gt 0 ]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--base) base="${2:?--base needs a ref}"; shift 2 ;;
|
|
27
|
+
--ready) ready=1; shift ;;
|
|
28
|
+
--classes) classes=1; shift ;;
|
|
29
|
+
--pr-body) body="${2:?--pr-body needs a file}"; shift 2 ;;
|
|
30
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
failures=0
|
|
35
|
+
fail() { printf 'FAIL: %s\n' "$*"; failures=$((failures + 1)); }
|
|
36
|
+
skip() { printf 'SKIP: %s\n' "$*"; }
|
|
37
|
+
|
|
38
|
+
# shellcheck source=scripts/lib/git-base.sh
|
|
39
|
+
. scripts/lib/git-base.sh
|
|
40
|
+
# shellcheck source=scripts/lib/classes.sh
|
|
41
|
+
. scripts/lib/classes.sh
|
|
42
|
+
# shellcheck source=scripts/lib/harness-conf.sh
|
|
43
|
+
. scripts/lib/harness-conf.sh
|
|
44
|
+
mb=$(resolve_base "$base") || exit 2
|
|
45
|
+
base="${base:-the default base}"
|
|
46
|
+
|
|
47
|
+
# Every path the change touches: changed against the merge base, deleted, or untracked.
|
|
48
|
+
changed_paths() {
|
|
49
|
+
{ git diff --name-only --no-renames "$mb"; git ls-files --others --exclude-standard; } | sort -u
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if [ "$classes" -eq 1 ]; then
|
|
53
|
+
paths=()
|
|
54
|
+
while IFS= read -r p; do
|
|
55
|
+
[ -n "$p" ] || continue
|
|
56
|
+
paths+=("$p")
|
|
57
|
+
printf '%s\t%s\n' "$(class_of "$p" "$mb")" "$p"
|
|
58
|
+
done < <(changed_paths)
|
|
59
|
+
printf 'review\t%s\n' "$(review_level "$mb" ${paths[@]+"${paths[@]}"})"
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# Prints the front-matter status of the record read from stdin.
|
|
64
|
+
status_of() {
|
|
65
|
+
tr -d '\r' | awk 'NR==1 && $0!="---" {exit} NR>1 && $0=="---" {exit}
|
|
66
|
+
NR>1 && /^status:/ {sub(/^status:[[:space:]]*/, ""); sub(/[[:space:]]+$/, ""); gsub(/^["\047]|["\047]$/, ""); print; exit}'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# True when the front matter of the record read from stdin has a non-empty field $1.
|
|
70
|
+
has_field() {
|
|
71
|
+
tr -d '\r' | awk -v k="$1" 'NR==1 && $0!="---" {exit 1} NR>1 && $0=="---" {exit 1}
|
|
72
|
+
NR>1 && index($0, k ":")==1 {v = substr($0, length(k) + 2); gsub(/[[:space:]"\047]/, "", v)
|
|
73
|
+
done = 1; exit (v == "" || v == "[]")}
|
|
74
|
+
END {if (!done) exit 1}'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Prints the record read from stdin without its front-matter status line.
|
|
78
|
+
without_status() {
|
|
79
|
+
awk 'NR==1 && $0=="---" {fm=1; print; next}
|
|
80
|
+
fm && $0=="---" {fm=0}
|
|
81
|
+
!(fm && /^status:/) {print}'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
is_record() {
|
|
85
|
+
case "$1" in docs/decisions/[0-9][0-9][0-9][0-9]-*.md) return 0 ;; esac
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Changed decision records as "<status letter><TAB><path>[<TAB><new path>]"; untracked files
|
|
90
|
+
# count as added. The template and other files in docs/decisions/ are the protect hook's concern.
|
|
91
|
+
changes=$( {
|
|
92
|
+
git diff --name-status -M "$mb" -- docs/decisions/
|
|
93
|
+
git ls-files --others --exclude-standard -- docs/decisions/ | awk '{print "A\t" $0}'
|
|
94
|
+
} )
|
|
95
|
+
added=()
|
|
96
|
+
while IFS=$'\t' read -r st p1 p2; do
|
|
97
|
+
[ -n "$st" ] || continue
|
|
98
|
+
case "$st" in
|
|
99
|
+
A) is_record "$p1" && added+=("$p1") ;;
|
|
100
|
+
C*) is_record "$p2" && added+=("$p2") ;;
|
|
101
|
+
esac
|
|
102
|
+
done <<< "$changes"
|
|
103
|
+
|
|
104
|
+
# True when the record numbered $1 is added by the change.
|
|
105
|
+
added_number() {
|
|
106
|
+
local p
|
|
107
|
+
for p in ${added[@]+"${added[@]}"}; do
|
|
108
|
+
case "$p" in "docs/decisions/$1-"*) return 0 ;; esac
|
|
109
|
+
done
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
check_added() {
|
|
114
|
+
local p="$1" status num
|
|
115
|
+
status=$(status_of < "$p")
|
|
116
|
+
case "$status" in
|
|
117
|
+
proposed|accepted|rejected) ;;
|
|
118
|
+
*) fail "$p: a new record starts as 'proposed', 'accepted', or 'rejected', got '${status:-none}'" ;;
|
|
119
|
+
esac
|
|
120
|
+
has_field scope < "$p" || fail "$p: front matter has no 'scope' (adr-template.md)"
|
|
121
|
+
has_field summary < "$p" || fail "$p: front matter has no 'summary' (adr-template.md)"
|
|
122
|
+
num=$(basename "$p" | cut -c1-4)
|
|
123
|
+
set -- docs/decisions/"$num"-*.md
|
|
124
|
+
[ "$#" -eq 1 ] || fail "$p: number $num is used by $# records"
|
|
125
|
+
# A new record takes the highest number on the base branch plus one, counting the records
|
|
126
|
+
# added before it in this change (documentation-rules.md, section 5).
|
|
127
|
+
local max next
|
|
128
|
+
max=$(git ls-tree --name-only "$mb" docs/decisions/ | sed -n 's#^docs/decisions/\([0-9]\{4\}\)-.*#\1#p' | sort | tail -n 1)
|
|
129
|
+
next=$(printf '%04d' $((10#${max:-0} + 1)))
|
|
130
|
+
for q in $(printf '%s\n' ${added[@]+"${added[@]}"} | sort); do
|
|
131
|
+
[ "$q" = "$p" ] && break
|
|
132
|
+
next=$(printf '%04d' $((10#$next + 1)))
|
|
133
|
+
done
|
|
134
|
+
[ "$num" = "$next" ] || fail "$p: a new record takes number $next (highest existing plus one), got $num"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
check_modified() {
|
|
138
|
+
local p="$1" old new os ns
|
|
139
|
+
old=$(git show "$mb:$p")
|
|
140
|
+
new=$(cat "$p")
|
|
141
|
+
if [ "$(printf '%s\n' "$old" | without_status)" != "$(printf '%s\n' "$new" | without_status)" ]; then
|
|
142
|
+
fail "$p: exists on $base and changed beyond its status line; reject it and add a new record instead (documentation-rules.md, section 5)"
|
|
143
|
+
fi
|
|
144
|
+
os=$(printf '%s\n' "$old" | status_of)
|
|
145
|
+
ns=$(printf '%s\n' "$new" | status_of)
|
|
146
|
+
case "$os -> $ns" in
|
|
147
|
+
"proposed -> accepted"|"proposed -> rejected"|"accepted -> deprecated") ;;
|
|
148
|
+
"accepted -> superseded by ADR-"[0-9][0-9][0-9][0-9])
|
|
149
|
+
added_number "${ns##*ADR-}" ||
|
|
150
|
+
fail "$p: superseded by ${ns##* }, which this change does not add" ;;
|
|
151
|
+
*) fail "$p: status '$os' -> '$ns' is not a transition of documentation-rules.md, section 3" ;;
|
|
152
|
+
esac
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# A pull request description, file $1: every item under "Assumptions and open questions" is
|
|
156
|
+
# "None" or carries one of the labels of docs/contributing.md, section 3.
|
|
157
|
+
check_body() {
|
|
158
|
+
local section items=0 line item doc
|
|
159
|
+
section=$(tr -d '\r' < "$1" | awk '/^## /{in_s = ($0 ~ /^## Assumptions and open questions[[:space:]]*$/); seen = seen || in_s; next}
|
|
160
|
+
in_s {print} END {exit !seen}') || {
|
|
161
|
+
fail "pull request description: no '## Assumptions and open questions' section"
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
while IFS= read -r line; do
|
|
165
|
+
indented=0
|
|
166
|
+
case "$line" in [[:space:]]*) indented=1 ;; esac
|
|
167
|
+
line="${line#"${line%%[![:space:]]*}"}"
|
|
168
|
+
case "$line" in
|
|
169
|
+
None|None.) items=$((items + 1)); continue ;;
|
|
170
|
+
"- "*|"* "*|"+ "*) item="${line#??}" ;;
|
|
171
|
+
[0-9]". "*|[0-9][0-9]". "*) item="${line#*. }" ;;
|
|
172
|
+
*) continue ;;
|
|
173
|
+
esac
|
|
174
|
+
# Labels are written as label: or `label:` or `label`:.
|
|
175
|
+
item=$(printf '%s' "$item" | sed -E 's/^`([a-z]+):?`:?/\1:/')
|
|
176
|
+
# An indented bullet without a label is a detail of the item above it.
|
|
177
|
+
if [ "$indented" = 1 ]; then
|
|
178
|
+
case "$item" in blocking:*|recorded:*|assumption:*|limit:*) ;; *) continue ;; esac
|
|
179
|
+
fi
|
|
180
|
+
items=$((items + 1))
|
|
181
|
+
case "$item" in
|
|
182
|
+
None|None.|assumption:*|limit:*) ;;
|
|
183
|
+
blocking:*) fail "blocking item still open: ${item:0:100}" ;;
|
|
184
|
+
recorded:*)
|
|
185
|
+
doc=$(printf '%s' "$item" | grep -o -E 'docs/[A-Za-z0-9_./-]+\.md' | head -n 1)
|
|
186
|
+
if [ -z "$doc" ]; then
|
|
187
|
+
fail "recorded item names no owner document (docs/...md): ${item:0:100}"
|
|
188
|
+
elif [ ! -f "$doc" ]; then
|
|
189
|
+
fail "recorded item names $doc, which does not exist"
|
|
190
|
+
fi ;;
|
|
191
|
+
*) fail "item labelled neither 'blocking:', 'recorded:', 'assumption:', nor 'limit:': ${item:0:100}" ;;
|
|
192
|
+
esac
|
|
193
|
+
done <<< "$section"
|
|
194
|
+
[ "$items" -gt 0 ] || fail "pull request description: 'Assumptions and open questions' is empty; write 'None'"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Prints the lines of section "## $1" of the description $2; fails when it has no such section.
|
|
198
|
+
section_of() {
|
|
199
|
+
tr -d '\r' < "$2" | awk -v h="## $1" '/^## / {l = $0; sub(/[[:space:]]+$/, "", l); s = (l == h); seen = seen || s; next}
|
|
200
|
+
s {print} END {exit !seen}'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Prints each top-level item of the section read from stdin on one line: a bullet or a numbered
|
|
204
|
+
# item with its continuation lines, or a bare "None".
|
|
205
|
+
items() {
|
|
206
|
+
awk '/^([-*+]|[0-9]+\.) / {if (it != "") print it; it = $0; sub(/^([-*+]|[0-9]+\.) +/, "", it); next}
|
|
207
|
+
/^[[:space:]]*$/ {next}
|
|
208
|
+
{l = $0; sub(/^[[:space:]]+/, "", l); if (it != "") it = it " " l; else print l}
|
|
209
|
+
END {if (it != "") print it}'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# "Acceptance and test plan": each item is "None" with its reason, or names at least one file in
|
|
213
|
+
# backticks, and every path in backticks exists in the working tree. A path is a backticked token
|
|
214
|
+
# without spaces that has a "/" or ends in a file extension that starts with a letter (`test_x.py`,
|
|
215
|
+
# not `v1.2.0`); a "#anchor", a "::test" suffix, or a ":line" suffix is ignored.
|
|
216
|
+
check_acceptance() {
|
|
217
|
+
local sec item paths p n=0
|
|
218
|
+
sec=$(section_of "Acceptance and test plan" "$1") || {
|
|
219
|
+
fail "pull request description: no '## Acceptance and test plan' section (docs/contributing.md, section 3)"
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
while IFS= read -r item; do
|
|
223
|
+
n=$((n + 1))
|
|
224
|
+
case "$item" in
|
|
225
|
+
None:*|'None ('*|'None -'*|'None, '*) continue ;;
|
|
226
|
+
None|None.) fail "Acceptance: 'None' needs its reason, such as 'None: documentation only'"; continue ;;
|
|
227
|
+
esac
|
|
228
|
+
paths=$(printf '%s\n' "$item" | grep -o '`[^`]*`' | tr -d '`' | sed -E 's/(::|#).*$//; s/:[0-9]+$//' |
|
|
229
|
+
grep -E '^([A-Za-z0-9_.@+-]+(/[A-Za-z0-9_.@+-]+)+|[A-Za-z0-9_@+-][A-Za-z0-9_.@+-]*\.[A-Za-z][A-Za-z0-9]*)$')
|
|
230
|
+
if [ -z "$paths" ]; then
|
|
231
|
+
fail "Acceptance item names no test file in backticks: ${item:0:100}"
|
|
232
|
+
continue
|
|
233
|
+
fi
|
|
234
|
+
while IFS= read -r p; do
|
|
235
|
+
[ -e "$p" ] || fail "Acceptance names $p, which does not exist"
|
|
236
|
+
done <<< "$paths"
|
|
237
|
+
done < <(printf '%s\n' "$sec" | items)
|
|
238
|
+
[ "$n" -gt 0 ] || fail "pull request description: 'Acceptance' is empty; write 'None' with the reason"
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Prints the rows of the Markdown tables read from stdin, one per line: the number of cells, then
|
|
242
|
+
# the cells, trimmed, separated by tabs. A row starts and ends with "|".
|
|
243
|
+
table_rows() {
|
|
244
|
+
awk -F'|' '/^[[:space:]]*\|/ {n = 0; out = ""
|
|
245
|
+
for (i = 2; i < NF; i++) {c = $i; gsub(/^[ \t]+|[ \t]+$/, "", c); out = out "\t" c; n++}
|
|
246
|
+
print n out}'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# "Metrics": the two tables of docs/contributing.md, section 3. A sensor row has six cells: the
|
|
250
|
+
# sensor, the class (blocker or suggestion), and the numbers found, fixed, limit, and declined,
|
|
251
|
+
# which add up; a blocker is never a limit. The measure table has each measure once: First
|
|
252
|
+
# check.sh (pass or fail); Ralph iterations, Santa rounds, Owner interventions, and Files outside
|
|
253
|
+
# the scope, each a number, optionally followed by details; and Regression of (none, or the #N of
|
|
254
|
+
# the pull request whose behaviour the change repairs).
|
|
255
|
+
check_metrics() {
|
|
256
|
+
local sec n a b c d e f m measures=
|
|
257
|
+
sec=$(section_of Metrics "$1") || {
|
|
258
|
+
fail "pull request description: no '## Metrics' section (docs/contributing.md, section 3)"
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
while IFS=$'\t' read -r n a b c d e f; do
|
|
262
|
+
case "$a" in Sensor|Measure|''|:-*|-*) continue ;; esac
|
|
263
|
+
if [ "$n" = 6 ]; then
|
|
264
|
+
case "$b" in blocker|suggestion) ;; *) fail "Metrics: $a: class '$b' is neither blocker nor suggestion"; continue ;; esac
|
|
265
|
+
for m in "$c" "$d" "$e" "$f"; do
|
|
266
|
+
case "$m" in ''|*[!0-9]*) fail "Metrics: $a $b: '$m' is not a number"; continue 2 ;; esac
|
|
267
|
+
done
|
|
268
|
+
[ "$c" -eq $((d + e + f)) ] ||
|
|
269
|
+
fail "Metrics: $a counts $c $b findings and answers $((d + e + f)); each finding is answered once"
|
|
270
|
+
[ "$b" != blocker ] || [ "$e" -eq 0 ] || fail "Metrics: $a: a blocker is fixed or declined, never a limit"
|
|
271
|
+
elif [ "$n" = 2 ]; then
|
|
272
|
+
case "$a:$b" in
|
|
273
|
+
'First check.sh:pass'*|'First check.sh:fail'*) ;;
|
|
274
|
+
'Ralph iterations:'[0-9]*|'Santa rounds:'[0-9]*|'Owner interventions:'[0-9]*|'Files outside the scope:'[0-9]*) ;;
|
|
275
|
+
'Regression of:none'*|'Regression of:#'[0-9]*) ;;
|
|
276
|
+
*) fail "Metrics: measure '$a' with value '$b' is outside the forms of docs/contributing.md, section 3"; continue ;;
|
|
277
|
+
esac
|
|
278
|
+
case "$measures|" in *"|$a|"*) fail "Metrics: measure '$a' appears twice" ;; esac
|
|
279
|
+
measures="$measures|$a"
|
|
280
|
+
else
|
|
281
|
+
fail "Metrics: a table row with $n cells; sensor rows have 6, measure rows 2"
|
|
282
|
+
fi
|
|
283
|
+
done < <(printf '%s\n' "$sec" | table_rows)
|
|
284
|
+
for m in 'First check.sh' 'Ralph iterations' 'Santa rounds' 'Owner interventions' 'Files outside the scope' 'Regression of'; do
|
|
285
|
+
case "$measures|" in *"|$m|"*) ;; *) fail "Metrics: no '$m' row" ;; esac
|
|
286
|
+
done
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Prints the path of the Markdown file of every `Decided by the owner, YYYY-MM-DD` marker the
|
|
290
|
+
# change adds, once per marker: added lines of the diff against the merge base, and untracked
|
|
291
|
+
# files. Markers live in owner documents, so other files, such as tests, are not read.
|
|
292
|
+
added_markers() {
|
|
293
|
+
local re='Decided by the owner, [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' f n
|
|
294
|
+
git diff --no-renames -U0 "$mb" -- '*.md' | awk -v re="$re" '/^\+\+\+ / {p = $2; sub(/^b\//, "", p); next}
|
|
295
|
+
/^\+/ && p != "/dev/null" {n = gsub(re, "&"); for (i = 0; i < n; i++) print p}'
|
|
296
|
+
while IFS= read -r -d '' f; do
|
|
297
|
+
n=$(grep -o -E "$re" "$f" 2>/dev/null | wc -l | tr -d ' ')
|
|
298
|
+
while [ "${n:-0}" -gt 0 ]; do printf '%s\n' "$f"; n=$((n - 1)); done
|
|
299
|
+
done < <(git ls-files -z --others --exclude-standard -- '*.md')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# "Owner decisions": each file names as many items as the markers the change adds to it.
|
|
303
|
+
check_markers() {
|
|
304
|
+
local markers sec need f have
|
|
305
|
+
markers=$(added_markers | sort | uniq -c)
|
|
306
|
+
[ -n "$markers" ] || return 0
|
|
307
|
+
sec=$(section_of "Owner decisions" "$1") || sec=
|
|
308
|
+
while read -r need f; do
|
|
309
|
+
have=$(printf '%s\n' "$sec" | items | grep -c -F -- "$f")
|
|
310
|
+
[ "$have" -ge "$need" ] ||
|
|
311
|
+
fail "$f: the change adds $need 'Decided by the owner' marker(s) and 'Owner decisions' names the file in $have item(s) (docs/contributing.md, section 3)"
|
|
312
|
+
done <<< "$markers"
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# A small diff (docs/contributing.md, section 1): a warning, never a failure, when the lines the
|
|
316
|
+
# change adds and deletes, generated files left out, exceed DIFF_WARN_LINES of .claude/harness.conf.
|
|
317
|
+
check_size() {
|
|
318
|
+
local limit n
|
|
319
|
+
limit=$(conf_get DIFF_WARN_LINES)
|
|
320
|
+
[ -n "$limit" ] || return 0
|
|
321
|
+
n=$(git diff --numstat "$mb" -- . ':(exclude).template/owned.sha256' ':(exclude).afe-answers.json' \
|
|
322
|
+
':(exclude).copier-answers.yml' ':(exclude).claude/settings.json' ':(exclude)docs/decisions/README.md' | awk '{a += $1 + $2} END {print a + 0}')
|
|
323
|
+
n=$((n + $(git ls-files -z --others --exclude-standard | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ')))
|
|
324
|
+
[ "$n" -le "$limit" ] || echo "WARNING: the change has $n changed lines, more than DIFF_WARN_LINES ($limit); split it, or say in the description why it is one change"
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# "Gate files": names every path of class gate the change touches; prints them.
|
|
328
|
+
check_gate_files() {
|
|
329
|
+
local sec p
|
|
330
|
+
sec=$(section_of "Gate files" "$1") || sec=
|
|
331
|
+
while IFS= read -r p; do
|
|
332
|
+
[ -n "$p" ] && [ "$(class_of "$p" "$mb")" = gate ] || continue
|
|
333
|
+
echo "class gate, reviewed after the merge: $p"
|
|
334
|
+
printf '%s\n' "$sec" | grep -q -F -- "$p" ||
|
|
335
|
+
fail "$p: class gate (documentation-rules.md, section 8); list it under 'Gate files'"
|
|
336
|
+
done < <(changed_paths)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if [ "$ready" -eq 0 ]; then
|
|
340
|
+
echo "== decision records: changes against $base"
|
|
341
|
+
[ -n "$changes" ] || skip "no decision record changed"
|
|
342
|
+
while IFS=$'\t' read -r st p1 p2; do
|
|
343
|
+
[ -n "$st" ] || continue
|
|
344
|
+
case "$st" in
|
|
345
|
+
A) is_record "$p1" && check_added "$p1" ;;
|
|
346
|
+
C*) is_record "$p2" && check_added "$p2" ;;
|
|
347
|
+
M|T) is_record "$p1" && check_modified "$p1" ;;
|
|
348
|
+
D) is_record "$p1" && fail "$p1: exists on $base and is deleted; records are never deleted, their status changes (documentation-rules.md, section 5)" ;;
|
|
349
|
+
R*) { is_record "$p1" || is_record "$p2"; } && fail "$p1: exists on $base and is renamed to $p2; records keep their file name" ;;
|
|
350
|
+
esac
|
|
351
|
+
done <<< "$changes"
|
|
352
|
+
echo "== decision index: docs/decisions/README.md matches the records"
|
|
353
|
+
scripts/gen-decisions-index.sh | cmp -s - docs/decisions/README.md ||
|
|
354
|
+
fail "docs/decisions/README.md is missing or stale; run scripts/gen-decisions-index.sh > docs/decisions/README.md"
|
|
355
|
+
else
|
|
356
|
+
echo "== merge readiness: records added by the change"
|
|
357
|
+
[ "${#added[@]}" -gt 0 ] || skip "no decision record added"
|
|
358
|
+
for p in ${added[@]+"${added[@]}"}; do
|
|
359
|
+
[ "$(status_of < "$p")" = proposed ] &&
|
|
360
|
+
fail "$p: still 'proposed'; the owner accepts or rejects it before the merge (docs/contributing.md, section 3)"
|
|
361
|
+
done
|
|
362
|
+
echo "== merge readiness: pull request description"
|
|
363
|
+
if [ -n "$body" ]; then
|
|
364
|
+
check_body "$body"
|
|
365
|
+
check_acceptance "$body"
|
|
366
|
+
check_markers "$body"
|
|
367
|
+
check_gate_files "$body"
|
|
368
|
+
check_metrics "$body"
|
|
369
|
+
check_size
|
|
370
|
+
if [ -x scripts/check-acceptance.sh ]; then
|
|
371
|
+
scripts/check-acceptance.sh --pr-body "$body" || fail "scripts/check-acceptance.sh failed (above)"
|
|
372
|
+
fi
|
|
373
|
+
else
|
|
374
|
+
skip "no pull request description given (--pr-body)"
|
|
375
|
+
fi
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
if [ "$failures" -gt 0 ]; then
|
|
379
|
+
echo "== decision checks failed: $failures"
|
|
380
|
+
exit 1
|
|
381
|
+
fi
|
|
382
|
+
echo "== decision checks passed (skipped checks listed above)"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Documentation checks, run by scripts/check.sh (AGENTS.md, section 6).
|
|
3
|
+
# CI (.github/workflows/docs.yml) runs the same tools through their official actions
|
|
4
|
+
# with the same configuration file, .markdownlint-cli2.jsonc, and the same link check.
|
|
5
|
+
# Requires Node.js (node and npx).
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
|
|
9
|
+
# A missing tool is not a failed check: exit 3, which the Stop hook reports without blocking.
|
|
10
|
+
for tool in node npx; do
|
|
11
|
+
command -v "$tool" >/dev/null || { echo "MISSING TOOL: $tool (see README.md, Checks)"; exit 3; }
|
|
12
|
+
done
|
|
13
|
+
|
|
14
|
+
echo "== markdownlint-cli2"
|
|
15
|
+
npx --yes markdownlint-cli2@0.24.0
|
|
16
|
+
|
|
17
|
+
echo "== links"
|
|
18
|
+
node scripts/check-links.mjs
|
|
19
|
+
|
|
20
|
+
echo "== documentation checks passed"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Internal link check of the Markdown files (docs/documentation-rules.md, section 7), with Node.js
|
|
3
|
+
// only: every relative link and image points at an existing file or directory, and every #anchor
|
|
4
|
+
// at a heading or an HTML id/name of the target Markdown file (the anchors GitHub generates).
|
|
5
|
+
// External links (with a scheme, such as https: or mailto:) are not checked: the author verifies
|
|
6
|
+
// them when adding them. Files ignored by Git are skipped; a file is read whole, so a link inside
|
|
7
|
+
// a fenced code block or a code span is not a link.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node scripts/check-links.mjs [file.md ...] default: every Markdown file of the repository
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const root = process.cwd();
|
|
15
|
+
|
|
16
|
+
function markdownFiles() {
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.md'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
});
|
|
22
|
+
return out.split('\0').filter((f) => f && existsSync(f));
|
|
23
|
+
} catch {
|
|
24
|
+
const found = [];
|
|
25
|
+
const walk = (dir) => {
|
|
26
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
28
|
+
const p = join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) walk(p);
|
|
30
|
+
else if (e.name.endsWith('.md')) found.push(relative(root, p));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
walk(root);
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Blanks fenced code blocks, HTML comments, and code spans, keeping line numbers and offsets.
|
|
39
|
+
function prose(text) {
|
|
40
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
41
|
+
return text
|
|
42
|
+
.replace(/^( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^\1?\2[^\n]*$|(?![\s\S]))/gm, blank)
|
|
43
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
44
|
+
.replace(/(`+)(?!`)[\s\S]*?[^`]\1(?!`)/g, blank);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The anchor GitHub derives from a heading: lowercase, punctuation removed, spaces to hyphens.
|
|
48
|
+
function slug(heading) {
|
|
49
|
+
const plain = heading
|
|
50
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
51
|
+
.replace(/<[^>]+>/g, '')
|
|
52
|
+
.replace(/(^|\W)[*_]{1,3}|[*_]{1,3}(?=\W|$)/g, '$1')
|
|
53
|
+
.replace(/`/g, '');
|
|
54
|
+
return plain
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '')
|
|
58
|
+
.replace(/ /g, '-');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const anchorCache = new Map();
|
|
62
|
+
function anchors(file) {
|
|
63
|
+
if (anchorCache.has(file)) return anchorCache.get(file);
|
|
64
|
+
const text = readFileSync(file, 'utf8');
|
|
65
|
+
const lines = text.replace(/^( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^\1?\2[^\n]*$|(?![\s\S]))/gm, (m) => m.replace(/[^\n]/g, ' ')).split('\n');
|
|
66
|
+
const found = new Set();
|
|
67
|
+
const seen = new Map();
|
|
68
|
+
const add = (heading) => {
|
|
69
|
+
const base = slug(heading);
|
|
70
|
+
const n = seen.get(base) ?? 0;
|
|
71
|
+
seen.set(base, n + 1);
|
|
72
|
+
found.add(n ? `${base}-${n}` : base);
|
|
73
|
+
};
|
|
74
|
+
lines.forEach((line, i) => {
|
|
75
|
+
const atx = /^ {0,3}#{1,6}[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/.exec(line);
|
|
76
|
+
if (atx) add(atx[1]);
|
|
77
|
+
else if (i > 0 && lines[i - 1].trim() && /^ {0,3}(=+|-+)[ \t]*$/.test(line) && !/^ {0,3}([-*_])( *\1){2,} *$/.test(lines[i - 1])) {
|
|
78
|
+
add(lines[i - 1].trim());
|
|
79
|
+
}
|
|
80
|
+
for (const m of line.matchAll(/<[a-z][^>]*\s(?:id|name)=["']([^"']+)["']/gi)) found.add(m[1]);
|
|
81
|
+
});
|
|
82
|
+
anchorCache.set(file, found);
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function* links(text) {
|
|
87
|
+
const src = prose(text);
|
|
88
|
+
const lineOf = (i) => src.slice(0, i).split('\n').length;
|
|
89
|
+
// Inline links and images: [text](target "title"), with one level of nested brackets.
|
|
90
|
+
for (const m of src.matchAll(/!?\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*(<[^>]*>|[^\s)]*(?:\([^\s)]*\)[^\s)]*)*)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g)) {
|
|
91
|
+
yield { target: m[1].replace(/^<|>$/g, ''), line: lineOf(m.index) };
|
|
92
|
+
}
|
|
93
|
+
// Reference definitions: [id]: target
|
|
94
|
+
for (const m of src.matchAll(/^ {0,3}\[[^\]]+\]:[ \t]*(<[^>]*>|\S+)/gm)) {
|
|
95
|
+
yield { target: m[1].replace(/^<|>$/g, ''), line: lineOf(m.index) };
|
|
96
|
+
}
|
|
97
|
+
// HTML links and images.
|
|
98
|
+
for (const m of src.matchAll(/<(?:a|img)\b[^>]*\s(?:href|src)=["']([^"']*)["']/gi)) {
|
|
99
|
+
yield { target: m[1], line: lineOf(m.index) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function check(file) {
|
|
104
|
+
const problems = [];
|
|
105
|
+
for (const { target, line } of links(readFileSync(file, 'utf8'))) {
|
|
106
|
+
if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) continue;
|
|
107
|
+
const hash = target.indexOf('#');
|
|
108
|
+
const pathPart = (hash === -1 ? target : target.slice(0, hash)).replace(/\?.*$/, '');
|
|
109
|
+
const fragment = hash === -1 ? '' : target.slice(hash + 1);
|
|
110
|
+
let dest = file;
|
|
111
|
+
if (pathPart) {
|
|
112
|
+
let decoded;
|
|
113
|
+
try {
|
|
114
|
+
decoded = decodeURIComponent(pathPart);
|
|
115
|
+
} catch {
|
|
116
|
+
decoded = pathPart;
|
|
117
|
+
}
|
|
118
|
+
dest = decoded.startsWith('/') ? join(root, decoded) : resolve(dirname(file), decoded);
|
|
119
|
+
if (!existsSync(dest)) {
|
|
120
|
+
problems.push(`${file}:${line}: ${target}: no such file or directory`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!fragment || !dest.endsWith('.md') || !statSync(dest).isFile()) continue;
|
|
125
|
+
let anchor;
|
|
126
|
+
try {
|
|
127
|
+
anchor = decodeURIComponent(fragment);
|
|
128
|
+
} catch {
|
|
129
|
+
anchor = fragment;
|
|
130
|
+
}
|
|
131
|
+
if (!anchors(dest).has(anchor) && !anchors(dest).has(anchor.toLowerCase())) {
|
|
132
|
+
problems.push(`${file}:${line}: ${target}: no heading or anchor #${anchor} in ${relative(root, dest) || file}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return problems;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = process.argv.length > 2 ? process.argv.slice(2) : markdownFiles();
|
|
139
|
+
const problems = files.flatMap(check);
|
|
140
|
+
for (const p of problems) console.log(`FAIL: ${p}`);
|
|
141
|
+
console.log(`== links: ${files.length} Markdown files, ${problems.length} broken`);
|
|
142
|
+
process.exit(problems.length ? 1 : 0);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Project checks (docs/documentation-rules.md, section 9): runs every scripts/checks.d/*.sh in name
|
|
3
|
+
# order. A project adds its own checks there; the template never writes to that directory.
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
shopt -s nullglob
|
|
7
|
+
|
|
8
|
+
checks=(scripts/checks.d/*.sh)
|
|
9
|
+
if [ "${#checks[@]}" -eq 0 ]; then
|
|
10
|
+
echo "SKIP: no project checks in scripts/checks.d/"
|
|
11
|
+
exit 0
|
|
12
|
+
fi
|
|
13
|
+
failures=0
|
|
14
|
+
for c in "${checks[@]}"; do
|
|
15
|
+
echo "== project check: $c"
|
|
16
|
+
if [ ! -x "$c" ]; then
|
|
17
|
+
echo "FAIL: $c is not executable"
|
|
18
|
+
failures=$((failures + 1))
|
|
19
|
+
elif ! "$c"; then
|
|
20
|
+
failures=$((failures + 1))
|
|
21
|
+
fi
|
|
22
|
+
done
|
|
23
|
+
if [ "$failures" -gt 0 ]; then
|
|
24
|
+
echo "== project checks failed: $failures"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
echo "== project checks passed"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Template integrity (docs/documentation-rules.md, sections 2 and 9): every template-owned file
|
|
3
|
+
# listed in .template/owned.txt matches its hash in .template/owned.sha256, AGENTS.md contains
|
|
4
|
+
# exactly one project block, the rule documents stay within their line budgets, and the generated
|
|
5
|
+
# .claude/settings.json is current. The hash of AGENTS.md ignores the content of its project block.
|
|
6
|
+
#
|
|
7
|
+
# Usage: scripts/check-template.sh [--write]
|
|
8
|
+
# --write record the current hashes; the template runs it after `create-afe-project` and
|
|
9
|
+
# `create-afe-project update`. Never run it to make a local edit of a template-owned file pass.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
cd "$(dirname "$0")/.."
|
|
12
|
+
|
|
13
|
+
list=.template/owned.txt
|
|
14
|
+
manifest=.template/owned.sha256
|
|
15
|
+
start='<!-- project:start -->'
|
|
16
|
+
end='<!-- project:end -->'
|
|
17
|
+
|
|
18
|
+
if command -v sha256sum >/dev/null; then sha() { sha256sum | cut -d' ' -f1; }
|
|
19
|
+
else sha() { shasum -a 256 | cut -d' ' -f1; }; fi
|
|
20
|
+
|
|
21
|
+
# Prints the content that the hash covers: AGENTS.md without the lines inside its project block.
|
|
22
|
+
content() {
|
|
23
|
+
if [ "$1" = AGENTS.md ]; then
|
|
24
|
+
awk -v s="$start" -v e="$end" '$0==s {print; skip=1; next} $0==e {skip=0} !skip' "$1"
|
|
25
|
+
else
|
|
26
|
+
cat "$1"
|
|
27
|
+
fi
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
paths() { grep -v -E '^[[:space:]]*(#|$)' "$list"; }
|
|
31
|
+
|
|
32
|
+
if [ "${1:-}" = "--write" ]; then
|
|
33
|
+
while IFS= read -r p; do
|
|
34
|
+
[ -f "$p" ] || { echo "FAIL: $p is listed in $list but missing" >&2; exit 1; }
|
|
35
|
+
done < <(paths)
|
|
36
|
+
paths | while IFS= read -r p; do printf '%s %s\n' "$(content "$p" | sha)" "$p"; done > "$manifest"
|
|
37
|
+
echo "== template manifest written: $manifest"
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
failures=0
|
|
42
|
+
fail() { printf 'FAIL: %s\n' "$*"; failures=$((failures + 1)); }
|
|
43
|
+
|
|
44
|
+
echo "== template-owned files match $manifest"
|
|
45
|
+
[ -f "$list" ] && [ -f "$manifest" ] || { echo "FAIL: $list or $manifest is missing"; exit 1; }
|
|
46
|
+
while IFS= read -r p; do
|
|
47
|
+
if [ ! -f "$p" ]; then
|
|
48
|
+
fail "$p: template-owned file is missing"
|
|
49
|
+
elif [ "$(content "$p" | sha)" != "$(awk -v p="$p" '$2==p {print $1}' "$manifest")" ]; then
|
|
50
|
+
fail "$p: differs from the template; change it in the template repository and update (docs/contributing.md, section 5)"
|
|
51
|
+
fi
|
|
52
|
+
done < <(paths)
|
|
53
|
+
[ "$(paths | sort)" = "$(awk '{print $2}' "$manifest" | sort)" ] ||
|
|
54
|
+
fail "$manifest does not list exactly the paths of $list; it is regenerated only by a template update"
|
|
55
|
+
|
|
56
|
+
echo "== AGENTS.md has exactly one project block"
|
|
57
|
+
[ "$(grep -c -x -F "$start" AGENTS.md)" = 1 ] && [ "$(grep -c -x -F "$end" AGENTS.md)" = 1 ] &&
|
|
58
|
+
[ "$(grep -n -x -F "$start" AGENTS.md | cut -d: -f1)" -lt "$(grep -n -x -F "$end" AGENTS.md | cut -d: -f1)" ] ||
|
|
59
|
+
fail "AGENTS.md: expected one '$start' line followed by one '$end' line"
|
|
60
|
+
|
|
61
|
+
echo "== AGENTS.md: template part at most 90 lines, project block at most 10 (documentation-rules.md, section 2)"
|
|
62
|
+
block=$(awk -v s="$start" -v e="$end" '$0==s {f=1; next} $0==e {f=0} f' AGENTS.md | wc -l | tr -d ' ')
|
|
63
|
+
total=$(wc -l < AGENTS.md | tr -d ' ')
|
|
64
|
+
[ "$block" -le 10 ] || fail "AGENTS.md: the project block has $block lines; at most 10"
|
|
65
|
+
[ $((total - block)) -le 90 ] || fail "AGENTS.md: the template part has $((total - block)) lines; at most 90 (a template defect)"
|
|
66
|
+
|
|
67
|
+
echo "== rule documents within their line budgets (documentation-rules.md, section 2)"
|
|
68
|
+
for spec in docs/documentation-rules.md:200 docs/contributing.md:200 docs/testing-rules.md:80; do
|
|
69
|
+
n=$(wc -l < "${spec%:*}" | tr -d ' ')
|
|
70
|
+
[ "$n" -le "${spec##*:}" ] || fail "${spec%:*}: $n lines; at most ${spec##*:} (a template defect)"
|
|
71
|
+
done
|
|
72
|
+
|
|
73
|
+
echo "== .claude/settings.json is generated from the template's and the project's settings"
|
|
74
|
+
out=$(scripts/gen-settings.sh --check 2>&1) || fail "${out#FAIL: }"
|
|
75
|
+
|
|
76
|
+
# Harness drift (docs/documentation-rules.md, section 7): a hook whose script is gone fails silently.
|
|
77
|
+
echo "== every hook command of .claude/settings.json exists and is executable"
|
|
78
|
+
while IFS= read -r hc; do
|
|
79
|
+
script=${hc%% *}; script=${script//\"/}; script=${script//\$CLAUDE_PROJECT_DIR/.}; script=${script//\$\{CLAUDE_PROJECT_DIR\}/.}
|
|
80
|
+
case "$script" in */*) [ -f "$script" ] && [ -x "$script" ] ||
|
|
81
|
+
fail ".claude/settings.json: the hook command $hc names $script, which is missing or not executable" ;; esac
|
|
82
|
+
done < <(node scripts/lib/json.mjs hooks)
|
|
83
|
+
|
|
84
|
+
if [ "$failures" -gt 0 ]; then
|
|
85
|
+
echo "== template checks failed: $failures"
|
|
86
|
+
exit 1
|
|
87
|
+
fi
|
|
88
|
+
echo "== template checks passed"
|