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,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# All checks: the single local entry point (AGENTS.md, section 6). Runs every part even when an
|
|
3
|
+
# earlier one fails, then reports which parts failed.
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
|
|
7
|
+
failed=()
|
|
8
|
+
run() {
|
|
9
|
+
echo "#### $1"
|
|
10
|
+
"$@" || failed+=("$1")
|
|
11
|
+
}
|
|
12
|
+
run scripts/check-docs.sh
|
|
13
|
+
run scripts/check-decisions.sh
|
|
14
|
+
run scripts/check-template.sh
|
|
15
|
+
run scripts/check-project.sh
|
|
16
|
+
|
|
17
|
+
if [ "${#failed[@]}" -gt 0 ]; then
|
|
18
|
+
echo "#### failed: ${failed[*]}"
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
echo "#### all checks passed"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The environment contract (docs/README.md, "Environment commands"; docs/contributing.md,
|
|
3
|
+
# section 1): one entry point for the commands an agent needs to set up, check, test end to end,
|
|
4
|
+
# and reset the project. `check` is scripts/check.sh; the others are the project's commands in
|
|
5
|
+
# .claude/harness.conf (SETUP, CHECK_FAST, E2E, RESET), run from the repository root with no
|
|
6
|
+
# stdin. A command that is not set prints SKIP and exits 0; otherwise the exit code is the
|
|
7
|
+
# command's, so a failure is machine-readable.
|
|
8
|
+
#
|
|
9
|
+
# Usage: scripts/env.sh setup|check|fast|e2e|reset
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
cd "$(dirname "$0")/.."
|
|
12
|
+
# shellcheck source=scripts/lib/harness-conf.sh
|
|
13
|
+
. scripts/lib/harness-conf.sh
|
|
14
|
+
|
|
15
|
+
case "${1:-}" in
|
|
16
|
+
setup) key=SETUP ;;
|
|
17
|
+
check) echo "== env check: scripts/check.sh"; exec scripts/check.sh < /dev/null ;;
|
|
18
|
+
fast) key=CHECK_FAST ;;
|
|
19
|
+
e2e) key=E2E ;;
|
|
20
|
+
reset) key=RESET ;;
|
|
21
|
+
*) echo "usage: scripts/env.sh setup|check|fast|e2e|reset" >&2; exit 2 ;;
|
|
22
|
+
esac
|
|
23
|
+
cmd=$(conf_get "$key")
|
|
24
|
+
if [ -z "$cmd" ]; then
|
|
25
|
+
echo "SKIP: $key is not set in .claude/harness.conf"
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
echo "== env $1: $cmd"
|
|
29
|
+
bash -c "$cmd" < /dev/null
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prints the decision index from the front matter of the records (docs/documentation-rules.md,
|
|
3
|
+
# section 2).
|
|
4
|
+
# Usage: scripts/gen-decisions-index.sh > docs/decisions/README.md
|
|
5
|
+
# scripts/check-decisions.sh fails when the committed index differs from this output.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
|
|
9
|
+
# Prints front-matter field $1 of the record read from stdin, without surrounding quotes.
|
|
10
|
+
field() {
|
|
11
|
+
tr -d '\r' | awk -v k="$1" 'NR==1 && $0!="---" {exit} NR>1 && $0=="---" {exit}
|
|
12
|
+
NR>1 && index($0, k ":")==1 {
|
|
13
|
+
v = substr($0, length(k) + 2); sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v)
|
|
14
|
+
if (v ~ /^".*"$/) v = substr(v, 2, length(v) - 2)
|
|
15
|
+
print v; exit}'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Prints the first level-one heading after the front matter.
|
|
19
|
+
title() {
|
|
20
|
+
awk 'NR==1 && $0=="---" {fm=1; next} fm && $0=="---" {fm=0; next}
|
|
21
|
+
!fm && /^# / {sub(/^# /, ""); print; exit}'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Prints $1 as a table cell: "–" when empty, pipes escaped.
|
|
25
|
+
cell() {
|
|
26
|
+
local v="${1:-}"
|
|
27
|
+
[ -n "$v" ] || v="–"
|
|
28
|
+
printf '%s' "${v//|/\\|}"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
shopt -s nullglob
|
|
32
|
+
records=(docs/decisions/[0-9][0-9][0-9][0-9]-*.md)
|
|
33
|
+
|
|
34
|
+
cat <<'EOF'
|
|
35
|
+
# Decision records
|
|
36
|
+
|
|
37
|
+
Generated by `scripts/gen-decisions-index.sh` from the front matter of the records; do not edit
|
|
38
|
+
by hand. Read this index first and open a record only when the task needs it. Which decisions get
|
|
39
|
+
a record, and where the other owner decisions live:
|
|
40
|
+
[`documentation-rules.md`](../documentation-rules.md), sections 3 and 5.
|
|
41
|
+
|
|
42
|
+
EOF
|
|
43
|
+
if [ "${#records[@]}" -eq 0 ]; then
|
|
44
|
+
echo "No records yet."
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
cat <<'EOF'
|
|
48
|
+
| ADR | Status | Scope | Decision | Summary | Supersedes |
|
|
49
|
+
| --- | --- | --- | --- | --- | --- |
|
|
50
|
+
EOF
|
|
51
|
+
for f in "${records[@]}"; do
|
|
52
|
+
name=$(basename "$f")
|
|
53
|
+
printf '| [%s](%s) | %s | %s | %s | %s | %s |\n' "${name:0:4}" "$name" \
|
|
54
|
+
"$(cell "$(field status < "$f")")" "$(cell "$(field scope < "$f")")" \
|
|
55
|
+
"$(cell "$(title < "$f")")" "$(cell "$(field summary < "$f")")" \
|
|
56
|
+
"$(cell "$(field supersedes < "$f" | tr -d '[]')")"
|
|
57
|
+
done
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Generates .claude/settings.json (docs/documentation-rules.md, section 9) from the template's
|
|
3
|
+
# .claude/settings.template.json and the project's .claude/settings.project.json: objects merge
|
|
4
|
+
# key by key, arrays are joined without duplicates (the template's entries first), and for any
|
|
5
|
+
# other value the project's wins. The project's top-level "remove" holds entries to take out of
|
|
6
|
+
# the template's arrays, such as {"remove": {"permissions": {"deny": ["Read(**/.env.*)"]}}}.
|
|
7
|
+
# Claude Code reads only the generated file.
|
|
8
|
+
#
|
|
9
|
+
# Usage: scripts/gen-settings.sh [--check]
|
|
10
|
+
# --check write nothing; fail when .claude/settings.json differs from the merge.
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
cd "$(dirname "$0")/.."
|
|
13
|
+
command -v node >/dev/null || { echo "gen-settings: Node.js is required" >&2; exit 2; }
|
|
14
|
+
exec node scripts/lib/json.mjs settings ${1:+"$1"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Sourced by scripts/check-decisions.sh. The path classes of docs/documentation-rules.md,
|
|
2
|
+
# section 8: approval, high, gate, normal, low; a path takes the highest class that applies.
|
|
3
|
+
# - approval, built in: AGENTS.md, the template-owned files of .template/owned.txt, the generated
|
|
4
|
+
# .template/owned.sha256, the answers file (.afe-answers.json, or the legacy
|
|
5
|
+
# .copier-answers.yml), and .claude/settings.json, the map
|
|
6
|
+
# docs/README.md, .claude/path-classes, .claude/settings.project.json, .mcp.json, the glob
|
|
7
|
+
# lines of a legacy .claude/protected-paths, any decision record that exists on the base, and
|
|
8
|
+
# any decision record whose status is not `proposed`;
|
|
9
|
+
# - then every `<class> <glob>` line of .claude/path-classes that matches;
|
|
10
|
+
# - a path that nothing matches is low when it is Markdown or generated, otherwise normal.
|
|
11
|
+
|
|
12
|
+
classes_file=.claude/path-classes
|
|
13
|
+
|
|
14
|
+
class_rank() {
|
|
15
|
+
case "$1" in approval) echo 5 ;; high) echo 4 ;; gate) echo 3 ;; normal) echo 2 ;; low) echo 1 ;; *) echo 0 ;; esac
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# True when $1 matches a glob line of the list file $2; '#' starts a comment.
|
|
19
|
+
glob_listed() {
|
|
20
|
+
local pat
|
|
21
|
+
[ -f "$2" ] || return 1
|
|
22
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
23
|
+
case "$pat" in ''|'#'*) continue ;; esac
|
|
24
|
+
# shellcheck disable=SC2254
|
|
25
|
+
case "$1" in $pat) return 0 ;; esac
|
|
26
|
+
done < "$2"
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# True when path $1 is an approval path by a built-in rule; $2 is the base commit.
|
|
31
|
+
builtin_approval() {
|
|
32
|
+
case "$1" in
|
|
33
|
+
AGENTS.md|.template/owned.sha256|.afe-answers.json|.copier-answers.yml|.claude/settings.json|docs/README.md|\
|
|
34
|
+
.claude/path-classes|.claude/settings.project.json|.mcp.json) return 0 ;;
|
|
35
|
+
docs/decisions/README.md) return 1 ;;
|
|
36
|
+
docs/decisions/[0-9][0-9][0-9][0-9]-*.md)
|
|
37
|
+
git cat-file -e "$2:$1" 2>/dev/null && return 0
|
|
38
|
+
[ -f "$1" ] && ! head -n 8 "$1" | grep -q -E '^status:[[:space:]]*proposed' && return 0 ;;
|
|
39
|
+
esac
|
|
40
|
+
glob_listed "$1" .template/owned.txt || glob_listed "$1" .claude/protected-paths
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# class_of <path> <base commit> [content]: prints the class of the path. With "content", approval
|
|
44
|
+
# is left out: the class of what the path contains, which decides its review.
|
|
45
|
+
class_of() {
|
|
46
|
+
local best=0 name= cls pat r
|
|
47
|
+
if [ "${3:-}" != content ] && builtin_approval "$1" "$2"; then echo approval; return; fi
|
|
48
|
+
if [ -f "$classes_file" ]; then
|
|
49
|
+
while read -r cls pat || [ -n "$cls" ]; do
|
|
50
|
+
case "$cls" in ''|'#'*) continue ;; esac
|
|
51
|
+
[ -n "$pat" ] || continue
|
|
52
|
+
[ "${3:-}" = content ] && [ "$cls" = approval ] && continue
|
|
53
|
+
# shellcheck disable=SC2254
|
|
54
|
+
case "$1" in $pat) ;; *) continue ;; esac
|
|
55
|
+
r=$(class_rank "$cls")
|
|
56
|
+
[ "$r" -gt "$best" ] && best=$r name=$cls
|
|
57
|
+
done < "$classes_file"
|
|
58
|
+
fi
|
|
59
|
+
if [ -z "$name" ]; then
|
|
60
|
+
case "$1" in
|
|
61
|
+
*.md|.template/owned.sha256|.afe-answers.json|.copier-answers.yml|.claude/settings.json) name=low ;;
|
|
62
|
+
*) name=normal ;;
|
|
63
|
+
esac
|
|
64
|
+
fi
|
|
65
|
+
echo "$name"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# review_level <base commit> <path>...: the review before the pull request that the paths need,
|
|
69
|
+
# by their highest content class: santa (high), one (gate, normal), or none (low). A template
|
|
70
|
+
# update needs none, because the template repository reviewed it: a change of
|
|
71
|
+
# the answers file whose other paths are all template-owned, generated, or seeded.
|
|
72
|
+
review_level() {
|
|
73
|
+
local mb="$1" p c best=1 update=1 answers=0
|
|
74
|
+
shift
|
|
75
|
+
for p in "$@"; do
|
|
76
|
+
case "$p" in
|
|
77
|
+
.afe-answers.json|.copier-answers.yml) answers=1 ;;
|
|
78
|
+
AGENTS.md|.template/owned.sha256|.claude/settings.json|docs/decisions/README.md) ;;
|
|
79
|
+
README.md|CLAUDE.md|.gitignore|docs/README.md|.claude/path-classes|.claude/harness.conf|\
|
|
80
|
+
.claude/settings.project.json|.claude/protected-paths) ;;
|
|
81
|
+
*) glob_listed "$p" .template/owned.txt || update=0 ;;
|
|
82
|
+
esac
|
|
83
|
+
c=$(class_rank "$(class_of "$p" "$mb" content)")
|
|
84
|
+
[ "$c" -gt "$best" ] && best=$c
|
|
85
|
+
done
|
|
86
|
+
if [ "$#" -eq 0 ] || { [ "$update" = 1 ] && [ "$answers" = 1 ]; } || [ "$best" -le 1 ]; then echo none
|
|
87
|
+
elif [ "$best" -ge 4 ]; then echo santa
|
|
88
|
+
else echo one; fi
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Sourced by scripts/check-decisions.sh and .claude/hooks/stop-check-docs.sh.
|
|
2
|
+
# resolve_base [ref] prints the commit that the current change is compared with:
|
|
3
|
+
# - with a ref: the merge base of that ref and HEAD; an unknown ref is an error (status 2);
|
|
4
|
+
# - without: the merge base with the first of origin/HEAD, origin/main, and main that exists (the
|
|
5
|
+
# branch a pull request goes into, never the current branch's own upstream); else HEAD (only
|
|
6
|
+
# uncommitted changes count); else, in a repository without commits, the empty tree.
|
|
7
|
+
resolve_base() {
|
|
8
|
+
local ref
|
|
9
|
+
if [ -n "${1:-}" ]; then
|
|
10
|
+
git rev-parse --verify -q "$1^{commit}" >/dev/null || { echo "unknown base ref '$1'" >&2; return 2; }
|
|
11
|
+
git merge-base "$1" HEAD
|
|
12
|
+
return
|
|
13
|
+
fi
|
|
14
|
+
for ref in origin/HEAD origin/main main; do
|
|
15
|
+
git rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 || continue
|
|
16
|
+
git merge-base "$ref" HEAD 2>/dev/null && return 0
|
|
17
|
+
done
|
|
18
|
+
if git rev-parse --verify -q HEAD >/dev/null; then
|
|
19
|
+
git rev-parse HEAD
|
|
20
|
+
else
|
|
21
|
+
git hash-object -t tree /dev/null
|
|
22
|
+
fi
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Sourced by the scripts that read .claude/harness.conf (docs/contributing.md, section 3).
|
|
2
|
+
# conf_get KEY [default]: prints the value of the last `KEY=value` line of the file, without
|
|
3
|
+
# surrounding quotes, or the default when the key is missing or empty. The file is read, never run.
|
|
4
|
+
conf_get() {
|
|
5
|
+
local v=
|
|
6
|
+
[ -f .claude/harness.conf ] &&
|
|
7
|
+
v=$(tr -d '\r' < .claude/harness.conf | awk -v k="$1" 'index($0, k "=") == 1 {v = substr($0, length(k) + 2)} END {print v}')
|
|
8
|
+
case "$v" in \"*\") v=${v#\"}; v=${v%\"} ;; \'*\') v=${v#\'}; v=${v%\'} ;; esac
|
|
9
|
+
printf '%s' "${v:-${2:-}}"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
# matching_files <globs>: prints the tracked and untracked files that match one of the
|
|
13
|
+
# space-separated shell globs, in which * also matches /.
|
|
14
|
+
matching_files() {
|
|
15
|
+
local f g
|
|
16
|
+
{ git ls-files; git ls-files --others --exclude-standard; } | sort -u | while IFS= read -r f; do
|
|
17
|
+
set -f
|
|
18
|
+
for g in $1; do
|
|
19
|
+
# shellcheck disable=SC2254
|
|
20
|
+
case "$f" in $g) printf '%s\n' "$f"; break ;; esac
|
|
21
|
+
done
|
|
22
|
+
set +f
|
|
23
|
+
done
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# The Ralph loop's cap in the Stop hooks (docs/contributing.md, section 3). ralph_block <name>
|
|
27
|
+
# <message>: below RALPH_MAX_ITERATIONS failing stops of hook <name>, prints the message and
|
|
28
|
+
# exits 2, which sends the agent back to fix it; at the cap, prints the message and that the cap
|
|
29
|
+
# is reached, resets the count, and exits 0, so the agent stops and reports the failure.
|
|
30
|
+
# ralph_pass <name>: resets the count when the hook's check passes. The counts live in the git
|
|
31
|
+
# directory, one file per hook and Claude Code session (RALPH_SESSION, the hook input's
|
|
32
|
+
# session_id), so an interrupted loop does not shorten the next one.
|
|
33
|
+
ralph_count_file() { printf '%s/ralph-stops-%s-%s' "$(git rev-parse --git-dir 2>/dev/null || echo /tmp)" "${RALPH_SESSION:-none}" "$1"; }
|
|
34
|
+
ralph_block() {
|
|
35
|
+
local f n cap
|
|
36
|
+
f=$(ralph_count_file "$1")
|
|
37
|
+
n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
|
|
38
|
+
cap=$(conf_get RALPH_MAX_ITERATIONS 5)
|
|
39
|
+
printf '%s\n' "$2" >&2
|
|
40
|
+
if [ "$n" -ge "$cap" ]; then
|
|
41
|
+
rm -f "$f"
|
|
42
|
+
printf 'Ralph loop cap of %s reached: stop now and report the failing command and its output (docs/contributing.md, section 3).\n' "$cap" >&2
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
echo "$n" > "$f"
|
|
46
|
+
exit 2
|
|
47
|
+
}
|
|
48
|
+
ralph_pass() { rm -f "$(ralph_count_file "$1")"; }
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// JSON for the hooks and scripts (docs/documentation-rules.md, section 9), with Node.js only.
|
|
3
|
+
//
|
|
4
|
+
// Usage: node scripts/lib/json.mjs <command> [arguments]
|
|
5
|
+
// fields <path>... read JSON from stdin; print the value at each dotted path, each
|
|
6
|
+
// followed by a NUL byte, an empty string when missing; the path
|
|
7
|
+
// @secret prints the kind of the first known token format in the text
|
|
8
|
+
// a PreToolUse input writes (content, new_string, edits, command)
|
|
9
|
+
// get <path> [default] read JSON from stdin; print the value at the path, or the default
|
|
10
|
+
// ask <reason> print the PreToolUse decision that asks the owner, with the reason
|
|
11
|
+
// deny <reason> print the PreToolUse decision that denies the action, with the reason
|
|
12
|
+
// settings [--check] merge .claude/settings.template.json and .claude/settings.project.json
|
|
13
|
+
// into .claude/settings.json (scripts/gen-settings.sh); with --check,
|
|
14
|
+
// write nothing and exit 1 when the file is missing or stale
|
|
15
|
+
// hooks print the command of every hook in .claude/settings.json, one per line
|
|
16
|
+
// prs <since> <separator> read `gh pr list --json number,createdAt,mergedAt,body` from stdin;
|
|
17
|
+
// print the count listed and the count merged before <since> on the
|
|
18
|
+
// first line, then for each pull request merged on or after <since> a
|
|
19
|
+
// line "<separator>PR <number> <hours to merge>" and its body
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
const stdin = () => readFileSync(0, 'utf8');
|
|
23
|
+
const at = (obj, path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
|
24
|
+
const text = (v) => (v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v));
|
|
25
|
+
|
|
26
|
+
function parse(input) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(input || 'null');
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// High-confidence token formats; a match asks the owner, it does not prove a live secret.
|
|
35
|
+
const SECRETS = [
|
|
36
|
+
['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
37
|
+
['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{60,}/],
|
|
38
|
+
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/],
|
|
39
|
+
['Slack token', /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
|
|
40
|
+
['Stripe live key', /\b[rs]k_live_[A-Za-z0-9]{20,}/],
|
|
41
|
+
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
42
|
+
['Anthropic API key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
|
|
43
|
+
['OpenAI API key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}/],
|
|
44
|
+
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
45
|
+
['private key', /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----/],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function secret(input) {
|
|
49
|
+
const t = input?.tool_input ?? {};
|
|
50
|
+
const texts = [t.content, t.new_string, t.command, ...(Array.isArray(t.edits) ? t.edits.map((e) => e?.new_string) : [])];
|
|
51
|
+
const text = texts.filter((x) => typeof x === 'string').join('\n');
|
|
52
|
+
return SECRETS.find(([, re]) => re.test(text))?.[0] ?? '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
56
|
+
|
|
57
|
+
function equal(a, b) {
|
|
58
|
+
if (a === b) return true;
|
|
59
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => equal(x, b[i]));
|
|
60
|
+
if (isObject(a) && isObject(b)) {
|
|
61
|
+
const ka = Object.keys(a);
|
|
62
|
+
return ka.length === Object.keys(b).length && ka.every((k) => k in b && equal(a[k], b[k]));
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Objects merge by key, arrays are a union in order without duplicates, anything else: b wins.
|
|
68
|
+
function merge(a, b) {
|
|
69
|
+
if (isObject(a) && isObject(b)) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
72
|
+
out[k] = k in a && k in b ? merge(a[k], b[k]) : k in b ? b[k] : a[k];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const x of [...a, ...b]) if (!out.some((y) => equal(x, y))) out.push(x);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
return b;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Takes out of a what r names: keys recurse, array elements equal to one of r are removed.
|
|
85
|
+
function drop(a, r) {
|
|
86
|
+
if (isObject(a) && isObject(r)) {
|
|
87
|
+
const out = { ...a };
|
|
88
|
+
for (const k of Object.keys(r)) if (k in out) out[k] = drop(out[k], r[k]);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(a) && Array.isArray(r)) return a.filter((x) => !r.some((y) => equal(x, y)));
|
|
92
|
+
return a;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function settings(check) {
|
|
96
|
+
const read = (f) => JSON.parse(readFileSync(f, 'utf8'));
|
|
97
|
+
const template = read('.claude/settings.template.json');
|
|
98
|
+
const project = existsSync('.claude/settings.project.json') ? read('.claude/settings.project.json') ?? {} : {};
|
|
99
|
+
const { remove = {}, ...rest } = project;
|
|
100
|
+
const merged = drop(merge(template, rest), remove);
|
|
101
|
+
const file = '.claude/settings.json';
|
|
102
|
+
if (check) {
|
|
103
|
+
if (!existsSync(file) || !equal(parse(readFileSync(file, 'utf8')), merged)) {
|
|
104
|
+
console.log('FAIL: .claude/settings.json is missing or stale; run scripts/gen-settings.sh');
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function prs(since, sep) {
|
|
113
|
+
const list = parse(stdin()) ?? [];
|
|
114
|
+
const out = [`${list.length} ${list.filter((p) => p.mergedAt < since).length}`];
|
|
115
|
+
for (const p of list) {
|
|
116
|
+
if (!(p.mergedAt >= since)) continue;
|
|
117
|
+
const hours = (Date.parse(p.mergedAt) - Date.parse(p.createdAt ?? p.mergedAt)) / 3600000;
|
|
118
|
+
out.push(`${sep}PR ${p.number} ${hours}`, (p.body ?? '').replace(/\r/g, ''));
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [command, ...args] = process.argv.slice(2);
|
|
124
|
+
switch (command) {
|
|
125
|
+
case 'fields': {
|
|
126
|
+
const data = parse(stdin());
|
|
127
|
+
process.stdout.write(args.map((p) => `${p === '@secret' ? secret(data) : text(at(data, p))}\0`).join(''));
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'get': {
|
|
131
|
+
const v = at(parse(stdin()), args[0]);
|
|
132
|
+
process.stdout.write(v == null || v === '' ? (args[1] ?? '') : text(v));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'ask':
|
|
136
|
+
case 'deny':
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: command, permissionDecisionReason: args[0] } })}\n`,
|
|
139
|
+
);
|
|
140
|
+
break;
|
|
141
|
+
case 'settings':
|
|
142
|
+
settings(args[0] === '--check');
|
|
143
|
+
break;
|
|
144
|
+
case 'hooks': {
|
|
145
|
+
const s = parse(existsSync('.claude/settings.json') ? readFileSync('.claude/settings.json', 'utf8') : '');
|
|
146
|
+
const commands = Object.values(s?.hooks ?? {})
|
|
147
|
+
.flat()
|
|
148
|
+
.flatMap((m) => m?.hooks ?? [])
|
|
149
|
+
.map((h) => h?.command)
|
|
150
|
+
.filter((c) => typeof c === 'string');
|
|
151
|
+
process.stdout.write(commands.map((c) => `${c.replace(/\n/g, ' ')}\n`).join(''));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'prs':
|
|
155
|
+
prs(args[0] ?? '', args[1] ?? '\x1f');
|
|
156
|
+
break;
|
|
157
|
+
default:
|
|
158
|
+
console.error(`json.mjs: unknown command ${command ?? '(none)'}`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Aggregates the "Metrics" tables of merged pull requests (docs/contributing.md, section 3): the
|
|
3
|
+
# findings of each sensor by class and how they were answered; the first-pass result of
|
|
4
|
+
# scripts/check.sh; Ralph iterations, Santa rounds, owner interventions, and files outside the
|
|
5
|
+
# scope, each with its median and maximum, since a distribution says more than a sum; the
|
|
6
|
+
# regression rate (pull requests that repair another's behaviour); and the time from opening to
|
|
7
|
+
# merge, which gh reports for every pull request. It measures which rules and checks earn their
|
|
8
|
+
# keep, so that one that never fires can be proposed for removal. Only table rows inside
|
|
9
|
+
# "## Metrics" are read; a pull request without that section counts for the time to merge only.
|
|
10
|
+
# Needs the GitHub CLI (`gh`, signed in) and Node.js.
|
|
11
|
+
#
|
|
12
|
+
# Usage: scripts/pr-metrics.sh [--since YYYY-MM-DD] [--limit N] [--repo owner/name]
|
|
13
|
+
# --since only pull requests merged on or after this date; default: all that gh lists
|
|
14
|
+
# --limit how many merged pull requests gh lists, newest first; default 500. When all of them
|
|
15
|
+
# are in the period, older ones may be missing, and the script says so.
|
|
16
|
+
# --repo the repository; default: the one of the current directory
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
since= limit=500 repo=()
|
|
20
|
+
while [ $# -gt 0 ]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--since) since="${2:?--since needs a date}"; shift 2 ;;
|
|
23
|
+
--limit) limit="${2:?--limit needs a number}"; shift 2 ;;
|
|
24
|
+
--repo) repo=(--repo "${2:?--repo needs owner/name}"); shift 2 ;;
|
|
25
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
26
|
+
esac
|
|
27
|
+
done
|
|
28
|
+
command -v gh >/dev/null || { echo "pr-metrics: the GitHub CLI gh is required" >&2; exit 2; }
|
|
29
|
+
command -v node >/dev/null || { echo "pr-metrics: Node.js is required" >&2; exit 2; }
|
|
30
|
+
|
|
31
|
+
json=$(gh pr list --state merged --limit "$limit" --json number,createdAt,mergedAt,body ${repo[@]+"${repo[@]}"}) ||
|
|
32
|
+
{ echo "pr-metrics: gh pr list failed" >&2; exit 2; }
|
|
33
|
+
sep=$(printf '\037')
|
|
34
|
+
feed=$(printf '%s' "$json" | node "$(dirname "$0")/lib/json.mjs" prs "$since" "$sep") || { echo "pr-metrics: cannot read the gh output" >&2; exit 2; }
|
|
35
|
+
read -r listed outside <<< "${feed%%$'\n'*}"
|
|
36
|
+
body=; case "$feed" in *$'\n'*) body=${feed#*$'\n'} ;; esac
|
|
37
|
+
printf '%s\n' "$body" |
|
|
38
|
+
awk -F'|' -v sep="$sep" -v since="${since:-the first listed}" '
|
|
39
|
+
function add(name, v) { cnt[name]++; val[name, cnt[name]] = v + 0; sum[name] += v }
|
|
40
|
+
function stats(name, i, j, t, n, a) {
|
|
41
|
+
n = cnt[name]; if (!n) return "none"
|
|
42
|
+
for (i = 1; i <= n; i++) a[i] = val[name, i]
|
|
43
|
+
for (i = 2; i <= n; i++) { t = a[i]; for (j = i - 1; j >= 1 && a[j] > t; j--) a[j + 1] = a[j]; a[j + 1] = t }
|
|
44
|
+
return sprintf("total %g, median %g, max %g, in %d pull requests", sum[name], (n % 2 ? a[(n + 1) / 2] : (a[n / 2] + a[n / 2 + 1]) / 2), a[n], n)
|
|
45
|
+
}
|
|
46
|
+
index($0, sep "PR ") == 1 { prs++; insec = 0; split(substr($0, length(sep) + 4), p, " "); add("hours to merge", sprintf("%.1f", p[2])); next }
|
|
47
|
+
/^## / { l = $0; sub(/[ \t]+$/, "", l); insec = (l == "## Metrics"); if (insec && !(prs in withm)) { withm[prs] = 1; nwith++ }; next }
|
|
48
|
+
!insec || !/^[ \t]*\|/ { next }
|
|
49
|
+
{
|
|
50
|
+
n = 0
|
|
51
|
+
for (i = 2; i < NF; i++) { c = $i; gsub(/^[ \t]+|[ \t]+$/, "", c); cell[++n] = c }
|
|
52
|
+
if (cell[1] == "Sensor" || cell[1] == "Measure" || cell[1] ~ /^:?-/) next
|
|
53
|
+
if (n == 6 && cell[2] ~ /^(blocker|suggestion)$/) {
|
|
54
|
+
k = cell[1] SUBSEP cell[2]
|
|
55
|
+
if (!(k in P)) order[++nk] = k
|
|
56
|
+
F[k] += cell[3]; X[k] += cell[4]; L[k] += cell[5]; D[k] += cell[6]; P[k]++
|
|
57
|
+
S[cell[1]] += cell[3]; if (!(cell[1] in seen)) { seen[cell[1]] = 1; sensors[++ns] = cell[1] }
|
|
58
|
+
} else if (n == 2) {
|
|
59
|
+
if (cell[1] == "First check.sh") { if (cell[2] ~ /^pass/) first_pass++; first_n++ }
|
|
60
|
+
else if (cell[1] == "Regression of") { reg_n++; if (cell[2] !~ /^none/) reg++ }
|
|
61
|
+
else if (cell[1] ~ /^(Ralph iterations|Santa rounds|Owner interventions|Files outside the scope)$/) add(tolower(cell[1]), cell[2])
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
END {
|
|
65
|
+
printf "== merged pull requests since %s: %d, with a Metrics section: %d\n", since, prs, nwith
|
|
66
|
+
printf "%-24s %-10s %6s %6s %6s %8s %4s\n", "sensor", "class", "found", "fixed", "limit", "declined", "PRs"
|
|
67
|
+
for (i = 1; i <= nk; i++) {
|
|
68
|
+
split(order[i], kk, SUBSEP)
|
|
69
|
+
printf "%-24s %-10s %6d %6d %6d %8d %4d\n", kk[1], kk[2], F[order[i]], X[order[i]], L[order[i]], D[order[i]], P[order[i]]
|
|
70
|
+
}
|
|
71
|
+
printf "first check.sh passed: %d of %d pull requests\n", first_pass, first_n
|
|
72
|
+
printf "regression rate: %d of %d pull requests repair another one\n", reg, reg_n
|
|
73
|
+
split("ralph iterations|santa rounds|owner interventions|files outside the scope|hours to merge", names, "|")
|
|
74
|
+
for (i = 1; i <= 5; i++) printf "%s: %s\n", names[i], stats(names[i])
|
|
75
|
+
for (i = 1; i <= ns; i++) if (S[sensors[i]] == 0) quiet = quiet (quiet == "" ? "" : ", ") sensors[i]
|
|
76
|
+
printf "== sensors without a finding in the period: %s\n", (quiet == "" ? "none" : quiet)
|
|
77
|
+
}'
|
|
78
|
+
if [ "$listed" -ge "$limit" ] && [ "$outside" -eq 0 ]; then
|
|
79
|
+
echo "WARNING: gh listed $listed pull requests, the --limit, all in the period; older ones in the period may be missing. Raise --limit."
|
|
80
|
+
fi
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Protects the default branch on GitHub (AGENTS.md, section 4): changes reach it only through pull
|
|
3
|
+
# requests whose `docs` and `decisions` integrity checks pass, and it cannot be force-pushed or
|
|
4
|
+
# deleted. It creates or updates the repository ruleset "protect default branch" with the GitHub
|
|
5
|
+
# CLI; run it once as a repository admin, after the first push. A change of repository settings
|
|
6
|
+
# is an owner action: an agent runs it only on the owner's explicit instruction.
|
|
7
|
+
#
|
|
8
|
+
# Usage: scripts/protect-branch.sh [--dry-run] [--repo owner/name]
|
|
9
|
+
# --dry-run print the ruleset and change nothing
|
|
10
|
+
# --repo the repository; default: the one of the current directory
|
|
11
|
+
# Rulesets need a public repository, or a GitHub plan that offers them for private ones.
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
cd "$(dirname "$0")/.."
|
|
14
|
+
|
|
15
|
+
dry= repo=
|
|
16
|
+
while [ $# -gt 0 ]; do
|
|
17
|
+
case "$1" in
|
|
18
|
+
--dry-run) dry=1; shift ;;
|
|
19
|
+
--repo) repo="${2:?--repo needs owner/name}"; shift 2 ;;
|
|
20
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
21
|
+
esac
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
name="protect default branch"
|
|
25
|
+
ruleset=$(node -e '
|
|
26
|
+
const [name] = process.argv.slice(1);
|
|
27
|
+
console.log(JSON.stringify({
|
|
28
|
+
name, target: "branch", enforcement: "active",
|
|
29
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
30
|
+
rules: [
|
|
31
|
+
{ type: "deletion" },
|
|
32
|
+
{ type: "non_fast_forward" },
|
|
33
|
+
{ type: "pull_request", parameters: { required_approving_review_count: 0, dismiss_stale_reviews_on_push: false,
|
|
34
|
+
require_code_owner_review: false, require_last_push_approval: false, required_review_thread_resolution: false } },
|
|
35
|
+
{ type: "required_status_checks", parameters: { strict_required_status_checks_policy: false,
|
|
36
|
+
required_status_checks: [{ context: "check" }, { context: "integrity" }] } },
|
|
37
|
+
],
|
|
38
|
+
}, null, 2));' "$name")
|
|
39
|
+
|
|
40
|
+
if [ -n "$dry" ]; then
|
|
41
|
+
printf '%s\n' "$ruleset"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
command -v gh > /dev/null || { echo "protect-branch: the GitHub CLI gh is required" >&2; exit 2; }
|
|
45
|
+
[ -n "$repo" ] || repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
46
|
+
id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name == \"$name\") | .id" | head -n 1)
|
|
47
|
+
if [ -n "$id" ]; then
|
|
48
|
+
printf '%s' "$ruleset" | gh api --method PUT "repos/$repo/rulesets/$id" --input - > /dev/null
|
|
49
|
+
echo "== updated the ruleset \"$name\" of $repo"
|
|
50
|
+
else
|
|
51
|
+
printf '%s' "$ruleset" | gh api --method POST "repos/$repo/rulesets" --input - > /dev/null
|
|
52
|
+
echo "== created the ruleset \"$name\" of $repo"
|
|
53
|
+
fi
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Acceptance check, the `acceptance_check` module (docs/contributing.md, section 3): every
|
|
3
|
+
# identifier that the section "Acceptance and test plan" of a pull request description names, by
|
|
4
|
+
# ID_PATTERN of .claude/harness.conf, appears as a whole word in at least one test file of
|
|
5
|
+
# TEST_GLOBS. It does not judge whether that test proves the identifier; the review does.
|
|
6
|
+
# scripts/check-decisions.sh --ready runs it. It skips when TEST_GLOBS is empty or the section
|
|
7
|
+
# names no identifier. A project may replace it with a stricter check in scripts/checks.d/.
|
|
8
|
+
#
|
|
9
|
+
# Usage: scripts/check-acceptance.sh --pr-body <file>
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
cd "$(dirname "$0")/.."
|
|
12
|
+
# shellcheck source=scripts/lib/harness-conf.sh
|
|
13
|
+
. scripts/lib/harness-conf.sh
|
|
14
|
+
|
|
15
|
+
body=
|
|
16
|
+
while [ $# -gt 0 ]; do
|
|
17
|
+
case "$1" in
|
|
18
|
+
--pr-body) body="${2:?--pr-body needs a file}"; shift 2 ;;
|
|
19
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
20
|
+
esac
|
|
21
|
+
done
|
|
22
|
+
[ -f "$body" ] || { echo "check-acceptance: --pr-body <file> is required" >&2; exit 2; }
|
|
23
|
+
|
|
24
|
+
echo "== acceptance: every identifier of the description is carried by a test file"
|
|
25
|
+
pattern=$(conf_get ID_PATTERN '(REQ|AC|INV)-[0-9]+')
|
|
26
|
+
globs=$(conf_get TEST_GLOBS)
|
|
27
|
+
[ -n "$globs" ] || { echo "SKIP: TEST_GLOBS is empty in .claude/harness.conf"; exit 0; }
|
|
28
|
+
ids=$(tr -d '\r' < "$body" |
|
|
29
|
+
awk '/^## / {l = $0; sub(/[[:space:]]+$/, "", l); s = (l == "## Acceptance and test plan"); next} s' |
|
|
30
|
+
grep -o -E -- "$pattern" | sort -u)
|
|
31
|
+
[ -n "$ids" ] || { echo "SKIP: the Acceptance section names no identifier"; exit 0; }
|
|
32
|
+
files=$(matching_files "$globs")
|
|
33
|
+
|
|
34
|
+
failures=0
|
|
35
|
+
while IFS= read -r id; do
|
|
36
|
+
hit=$(printf '%s\n' "$files" | while IFS= read -r f; do
|
|
37
|
+
[ -f "$f" ] && grep -q -F -w -- "$id" "$f" 2>/dev/null && { printf '%s\n' "$f"; break; }
|
|
38
|
+
done)
|
|
39
|
+
if [ -n "$hit" ]; then
|
|
40
|
+
echo "ok: $id in $hit"
|
|
41
|
+
else
|
|
42
|
+
echo "FAIL: $id: no test file of TEST_GLOBS carries it (docs/testing-rules.md, section 1)"
|
|
43
|
+
failures=$((failures + 1))
|
|
44
|
+
fi
|
|
45
|
+
done <<< "$ids"
|
|
46
|
+
[ "$failures" -eq 0 ] || { echo "== acceptance check failed: $failures"; exit 1; }
|
|
47
|
+
echo "== acceptance check passed"
|