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.
Files changed (59) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -2
  4. package/bin/create-afe-project.js +123 -0
  5. package/package.json +43 -4
  6. package/src/answers.js +147 -0
  7. package/src/config.js +152 -0
  8. package/src/project.js +217 -0
  9. package/src/render.js +51 -0
  10. package/template/.claude/harness.conf +47 -0
  11. package/template/.claude/hooks/post-edit-check.sh +38 -0
  12. package/template/.claude/hooks/protect-docs.sh +315 -0
  13. package/template/.claude/hooks/stop-check-decisions.sh +25 -0
  14. package/template/.claude/hooks/stop-check-docs.sh +29 -0
  15. package/template/.claude/hooks/stop-check-template.sh +23 -0
  16. package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
  17. package/template/.claude/path-classes +61 -0
  18. package/template/.claude/settings.project.json +5 -0
  19. package/template/.claude/settings.template.json +76 -0
  20. package/template/.claude/skills/afe-init/SKILL.md +104 -0
  21. package/template/.claude/skills/create-pr/SKILL.md.jinja +107 -0
  22. package/template/.claude/skills/update-template/SKILL.md +40 -0
  23. package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +46 -0
  24. package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +56 -0
  25. package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
  26. package/template/.github/workflows/decisions.yml.jinja +47 -0
  27. package/template/.github/workflows/docs.yml.jinja +40 -0
  28. package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +51 -0
  29. package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +49 -0
  30. package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +40 -0
  31. package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
  32. package/template/.markdownlint-cli2.jsonc +27 -0
  33. package/template/.template/owned.txt.jinja +63 -0
  34. package/template/AGENTS.md.jinja +91 -0
  35. package/template/CLAUDE.md +3 -0
  36. package/template/README.md.jinja +41 -0
  37. package/template/_gitignore +14 -0
  38. package/template/docs/README.md.jinja +44 -0
  39. package/template/docs/contributing.md.jinja +206 -0
  40. package/template/docs/decisions/adr-template.md +38 -0
  41. package/template/docs/documentation-rules.md.jinja +200 -0
  42. package/template/docs/testing-rules.md +80 -0
  43. package/template/scripts/check-decisions.sh +382 -0
  44. package/template/scripts/check-docs.sh +20 -0
  45. package/template/scripts/check-links.mjs +142 -0
  46. package/template/scripts/check-project.sh +27 -0
  47. package/template/scripts/check-template.sh +88 -0
  48. package/template/scripts/check.sh +21 -0
  49. package/template/scripts/env.sh +29 -0
  50. package/template/scripts/gen-decisions-index.sh +57 -0
  51. package/template/scripts/gen-settings.sh +14 -0
  52. package/template/scripts/lib/classes.sh +89 -0
  53. package/template/scripts/lib/git-base.sh +23 -0
  54. package/template/scripts/lib/harness-conf.sh +48 -0
  55. package/template/scripts/lib/json.mjs +160 -0
  56. package/template/scripts/pr-metrics.sh +80 -0
  57. package/template/scripts/protect-branch.sh +53 -0
  58. package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
  59. 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"