@erclx/canon 5.8.0 → 5.9.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 (70) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/git-followup/SKILL.md +1 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  5. package/claude/skills/review-craft/SKILL.md +63 -7
  6. package/claude/skills/review-craft/references/adopted.md +49 -0
  7. package/docs/agents/commands.md +2 -0
  8. package/docs/agents/hooks.md +56 -0
  9. package/docs/agents/index.md +1 -0
  10. package/docs/agents/install-and-sync.md +1 -1
  11. package/docs/agents/markdown-audit.md +1 -1
  12. package/docs/agents/pr-evidence.md +33 -17
  13. package/docs/agents/records-push.md +1 -1
  14. package/docs/agents/records-validate.md +1 -1
  15. package/docs/agents/scripting.md +6 -0
  16. package/docs/agents/tasks-archive.md +4 -2
  17. package/docs/agents/tasks.md +1 -1
  18. package/docs/agents/worktrees.md +2 -2
  19. package/docs/target/scenarios.md +2 -2
  20. package/package.json +1 -1
  21. package/src/claude/skills-audit.ts +1 -0
  22. package/src/cli.ts +2 -0
  23. package/src/commands/claude.ts +1 -1
  24. package/src/commands/docs.ts +51 -11
  25. package/src/commands/hooks.ts +67 -0
  26. package/src/commands/pr.ts +146 -76
  27. package/src/commands/standards.ts +33 -2
  28. package/src/commands/tasks.ts +45 -0
  29. package/src/commands/tooling.ts +3 -3
  30. package/src/commands/upgrade.ts +3 -2
  31. package/src/commands/worktrees.ts +4 -3
  32. package/src/docs/list.ts +103 -0
  33. package/src/docs/read.ts +63 -19
  34. package/src/gov/stacks.ts +1 -1
  35. package/src/help.ts +2 -0
  36. package/src/hooks/post-merge.ts +331 -0
  37. package/src/pr/evidence.ts +21 -0
  38. package/src/records/validate.ts +1 -38
  39. package/src/standards/list.ts +33 -0
  40. package/src/standards/read.ts +13 -0
  41. package/src/standards/scope.ts +56 -0
  42. package/src/sync/check.ts +38 -4
  43. package/src/tasks/archive.ts +168 -3
  44. package/src/tooling/inject.ts +4 -3
  45. package/src/tooling/manifest.ts +62 -8
  46. package/src/tooling/scan.ts +4 -3
  47. package/src/tooling/stamp.ts +11 -2
  48. package/src/tooling/subfolder.ts +7 -3
  49. package/tooling/astro/reference.md +1 -1
  50. package/tooling/base/configs/scripts/verify.json +3 -0
  51. package/tooling/base/configs/scripts/verify.sh +1 -110
  52. package/tooling/base/configs/scripts/verify.ts +378 -0
  53. package/tooling/base/manifest.toml +1 -0
  54. package/tooling/base/reference.md +5 -3
  55. package/tooling/go/configs/scripts/verify.json +10 -0
  56. package/tooling/go/reference.md +5 -4
  57. package/tooling/php/configs/scripts/verify.json +7 -0
  58. package/tooling/php/reference.md +5 -4
  59. package/tooling/python/configs/scripts/verify.json +4 -0
  60. package/tooling/python/reference.md +3 -3
  61. package/tooling/web/configs/scripts/verify.json +3 -0
  62. package/tooling/web/reference.md +3 -3
  63. package/scripts/docs/list.sh +0 -184
  64. package/scripts/lib/frontmatter.sh +0 -30
  65. package/scripts/standards/list.sh +0 -115
  66. package/src/commands/pass-through.ts +0 -39
  67. package/tooling/go/configs/scripts/verify.sh +0 -142
  68. package/tooling/php/configs/scripts/verify.sh +0 -147
  69. package/tooling/python/configs/scripts/verify.sh +0 -141
  70. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -1,184 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
- source "$PROJECT_ROOT/scripts/lib/frontmatter.sh"
10
-
11
- DOCS_DIR="$PROJECT_ROOT/docs"
12
- CONTEXT_DIR="$PROJECT_ROOT/canon/context"
13
-
14
- INTERNAL_TOPICS=" ci development sandbox "
15
-
16
- show_help() {
17
- echo -e "${GREY}┌${NC}"
18
- echo -e "${GREY}├${NC} ${WHITE}Usage:${NC} canon docs list [options]"
19
- echo -e "${GREY}│${NC}"
20
- echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
21
- echo -e "${GREY}│${NC} --json ${GREY}# Emit machine-readable JSON${NC}"
22
- echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
23
- echo -e "${GREY}└${NC}"
24
- exit 0
25
- }
26
-
27
- is_target_facing() {
28
- case "$1" in
29
- "Agent surface" | "Domain references") return 0 ;;
30
- *) return 1 ;;
31
- esac
32
- }
33
-
34
- is_internal_topic() {
35
- case "$INTERNAL_TOPICS" in
36
- *" $1 "*) return 0 ;;
37
- *) return 1 ;;
38
- esac
39
- }
40
-
41
- # Emits `name<TAB>description<TAB>category<TAB>target` per target-facing doc,
42
- # sorted so a domain split into a folder lands in its alphabetical place rather
43
- # than after every file. A folder declares its category on its own index, since
44
- # the allowlist is what separates a target-facing doc from a workflow one and a
45
- # split domain is not exempt from it. A sub-area file declares its own, which is
46
- # what keeps a page's listing membership unchanged by the folder it moves into.
47
- collect_docs() {
48
- local file name description category
49
- {
50
- while IFS= read -r file; do
51
- name=$(basename "$file" .md)
52
- [ "$name" = "index" ] && continue
53
- category=$(read_frontmatter_field "$file" "category")
54
- is_target_facing "$category" || continue
55
- description=$(read_frontmatter_field "$file" "description")
56
- printf '%s\t%s\t%s\t%s\n' "$name" "$description" "$category" "docs/$name.md"
57
- done < <(find "$DOCS_DIR" -maxdepth 1 -type f -name "*.md")
58
-
59
- # A split domain is named by its folder and described by its generated
60
- # index, which carries subtitle where a sibling file carries description
61
- while IFS= read -r file; do
62
- name=$(basename "$(dirname "$file")")
63
- category=$(read_frontmatter_field "$file" "category")
64
- is_target_facing "$category" || continue
65
- description=$(read_frontmatter_field "$file" "subtitle")
66
- printf '%s\t%s\t%s\t%s\n' "$name" "$description" "$category" "docs/$name/index.md"
67
- done < <(find "$DOCS_DIR" -mindepth 2 -maxdepth 2 -type f -name "index.md")
68
-
69
- # A sub-area file is named and described like a sibling file, and reaches a
70
- # caller by that bare name, so the depth it sits at is not the listing's
71
- # business. Only a folder carrying an index is walked, matching resolveTopic
72
- while IFS= read -r file; do
73
- name=$(basename "$file" .md)
74
- [ "$name" = "index" ] && continue
75
- [ -f "$(dirname "$file")/index.md" ] || continue
76
- category=$(read_frontmatter_field "$file" "category")
77
- is_target_facing "$category" || continue
78
- description=$(read_frontmatter_field "$file" "description")
79
- printf '%s\t%s\t%s\t%s\n' "$name" "$description" "$category" "docs/$(basename "$(dirname "$file")")/$name.md"
80
- done < <(find "$DOCS_DIR" -mindepth 2 -maxdepth 2 -type f -name "*.md")
81
- } | sort
82
- }
83
-
84
- # Emits `name<TAB>description<TAB>target` per context entry, sorted so a domain
85
- # split into a folder lands in its alphabetical place rather than after every
86
- # file, the way listTopics in src/docs/read.ts sorts both together.
87
- #
88
- # It stops at the folder where listTopics goes on to name each sub-area file.
89
- # This is the downstream catalog and that one answers what a caller could have
90
- # typed, so a reachable name absent here is the same divergence the internal
91
- # topics already carry, and `canon docs docs` states it.
92
- collect_context() {
93
- local file name description
94
- {
95
- while IFS= read -r file; do
96
- name=$(basename "$file" .md)
97
- [ "$name" = "index" ] && continue
98
- is_internal_topic "$name" && continue
99
- description=$(read_frontmatter_field "$file" "description")
100
- printf '%s\t%s\t%s\n' "$name" "$description" "canon/context/$name.md"
101
- done < <(find "$CONTEXT_DIR" -maxdepth 1 -type f -name "*.md")
102
-
103
- # A split domain is named by its folder and described by its generated
104
- # index, which carries subtitle where a sibling file carries description
105
- while IFS= read -r file; do
106
- name=$(basename "$(dirname "$file")")
107
- is_internal_topic "$name" && continue
108
- description=$(read_frontmatter_field "$file" "subtitle")
109
- printf '%s\t%s\t%s\n' "$name" "$description" "canon/context/$name/index.md"
110
- done < <(find "$CONTEXT_DIR" -mindepth 2 -maxdepth 2 -type f -name "index.md")
111
- } | sort
112
- }
113
-
114
- list_text() {
115
- local name description category target
116
- log_step "Docs"
117
- while IFS=$'\t' read -r name description category target; do
118
- log_info "$name : $description"
119
- done < <(collect_docs)
120
-
121
- # Absent in a registry install, which ships docs/ without .claude/
122
- if [ -d "$CONTEXT_DIR" ]; then
123
- log_step "Domain context"
124
- while IFS=$'\t' read -r name description target; do
125
- log_info "$name : $description"
126
- done < <(collect_context)
127
- fi
128
- }
129
-
130
- emit_json_entry() {
131
- local name="$1" description="$2" category="$3" target="$4"
132
- [ "$JSON_FIRST" -eq 0 ] && printf ','
133
- jq -nc \
134
- --arg name "$name" \
135
- --arg description "$description" \
136
- --arg category "$category" \
137
- --arg target "$target" \
138
- '{name: $name, description: $description, category: $category, target: $target}'
139
- JSON_FIRST=0
140
- }
141
-
142
- list_json() {
143
- local name description category target
144
- JSON_FIRST=1
145
- printf '['
146
- while IFS=$'\t' read -r name description category target; do
147
- emit_json_entry "$name" "$description" "$category" "$target"
148
- done < <(collect_docs)
149
-
150
- if [ -d "$CONTEXT_DIR" ]; then
151
- while IFS=$'\t' read -r name description target; do
152
- emit_json_entry "$name" "$description" "" "$target"
153
- done < <(collect_context)
154
- fi
155
- printf ']'
156
- }
157
-
158
- main() {
159
- local json=0
160
-
161
- while [[ $# -gt 0 ]]; do
162
- case "$1" in
163
- -h | --help) show_help ;;
164
- --json)
165
- json=1
166
- shift
167
- ;;
168
- *) log_error "Unknown option: $1" ;;
169
- esac
170
- done
171
-
172
- trap close_timeline EXIT
173
-
174
- if [ "$json" -eq 1 ]; then
175
- printf '{"docs":'
176
- list_json
177
- printf '}\n'
178
- exit 0
179
- fi
180
-
181
- list_text
182
- }
183
-
184
- main "$@"
@@ -1,30 +0,0 @@
1
- #!/usr/bin/env bash
2
-
3
- # Reads one frontmatter field from a markdown file.
4
- #
5
- # Kept in bash because the list commands call it once per field per file
6
- # inside a loop, where shelling into the CLI would cost a process per read.
7
- # Index logic is TypeScript, in src/indexes/, and does not belong here.
8
- read_frontmatter_field() {
9
- local file="$1"
10
- local key="$2"
11
- awk -v key="$key" '
12
- NR == 1 && /^---$/ { in_fm = 1; next }
13
- in_fm && /^---$/ { exit }
14
- in_fm {
15
- prefix = key ": "
16
- if (index($0, prefix) == 1) {
17
- val = substr($0, length(prefix) + 1)
18
- if (length(val) >= 2) {
19
- first = substr(val, 1, 1)
20
- last = substr(val, length(val), 1)
21
- if ((first == "\"" && last == "\"") || (first == "'\''" && last == "'\''")) {
22
- val = substr(val, 2, length(val) - 2)
23
- }
24
- }
25
- print val
26
- exit
27
- }
28
- }
29
- ' "$file"
30
- }
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
- source "$PROJECT_ROOT/scripts/lib/frontmatter.sh"
10
-
11
- STANDARDS_DIR="$PROJECT_ROOT/standards"
12
-
13
- show_help() {
14
- echo -e "${GREY}┌${NC}"
15
- echo -e "${GREY}├${NC} ${WHITE}Usage:${NC} canon standards list [options]"
16
- echo -e "${GREY}│${NC}"
17
- echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
18
- echo -e "${GREY}│${NC} --json ${GREY}# Emit machine-readable JSON${NC}"
19
- echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
20
- echo -e "${GREY}└${NC}"
21
- exit 0
22
- }
23
-
24
- json_escape() {
25
- local s="$1"
26
- s="${s//\\/\\\\}"
27
- s="${s//\"/\\\"}"
28
- printf '%s' "$s"
29
- }
30
-
31
- # Emits the paths a standard's `## Scope` statement declares, as a JSON array.
32
- # The declaration is prose, so the contract is narrow on purpose: backticked
33
- # paths in the first sentence of the statement, or `*` for an attribute standard,
34
- # which names no path and says so in the same statement. An empty array means the
35
- # statement did not parse, which consumers report rather than skip.
36
- read_applies_to() {
37
- local file="$1"
38
- local scope first paths
39
-
40
- scope=$(awk '/^## Scope[[:space:]]*$/ {found = 1; next} found && NF {print; exit}' "$file")
41
- first="${scope%%. *}"
42
- paths=$(printf '%s' "$first" | grep -o '`[^`]*`' | tr -d '`' || true)
43
-
44
- if [ -z "$paths" ] && [[ "$scope" == *"attribute standard"* ]]; then
45
- paths="*"
46
- fi
47
-
48
- printf '%s\n' "$paths" | jq -Rnc '[inputs | select(length > 0)]'
49
- }
50
-
51
- list_text() {
52
- log_step "Standards"
53
- local file name title
54
- while IFS= read -r file; do
55
- name=$(basename "$file" .md)
56
- [ "$name" = "index" ] && continue
57
- title=$(read_frontmatter_field "$file" "description")
58
- log_info "$name : $title"
59
- done < <(find "$STANDARDS_DIR" -maxdepth 1 -type f -name "*.md" | sort)
60
- }
61
-
62
- # No `target` field. It named where a standard installed, and nothing installs
63
- # one now, so every value it could carry is either a path only this repository
64
- # has or a duplicate of what `canon standards <name>` reports. `content` already
65
- # carries the document, which is what a consumer wanted the path for.
66
- list_json() {
67
- local first=1
68
- local file name title applies_to
69
- printf '['
70
- while IFS= read -r file; do
71
- name=$(basename "$file" .md)
72
- [ "$name" = "index" ] && continue
73
- title=$(read_frontmatter_field "$file" "description")
74
- applies_to=$(read_applies_to "$file")
75
- if [ "$first" -eq 0 ]; then
76
- printf ','
77
- fi
78
- jq -nc \
79
- --arg name "$name" \
80
- --arg description "$title" \
81
- --argjson appliesTo "$applies_to" \
82
- --rawfile content "$file" \
83
- '{name: $name, description: $description, appliesTo: $appliesTo, content: $content}'
84
- first=0
85
- done < <(find "$STANDARDS_DIR" -maxdepth 1 -type f -name "*.md" | sort)
86
- printf ']'
87
- }
88
-
89
- main() {
90
- local json=0
91
-
92
- while [[ $# -gt 0 ]]; do
93
- case "$1" in
94
- -h | --help) show_help ;;
95
- --json)
96
- json=1
97
- shift
98
- ;;
99
- *) log_error "Unknown option: $1" ;;
100
- esac
101
- done
102
-
103
- trap close_timeline EXIT
104
-
105
- if [ "$json" -eq 1 ]; then
106
- printf '{"standards":'
107
- list_json
108
- printf '}\n'
109
- exit 0
110
- fi
111
-
112
- list_text
113
- }
114
-
115
- main "$@"
@@ -1,39 +0,0 @@
1
- import type { Command } from 'commander'
2
- import { execScript } from '@/exec'
3
- import { intro } from '@/ui'
4
-
5
- /**
6
- * Registers the verbs a domain has not migrated yet, each forwarding to
7
- * `scripts/<domain>/<verb>.sh`. Naming them instead of catching everything on
8
- * the parent gives each verb its own `--help` and lets a migrated sibling sit
9
- * alongside them.
10
- */
11
- export function registerPassThroughVerbs(
12
- parent: Command,
13
- domain: string,
14
- verbs: readonly string[],
15
- ): void {
16
- for (const verb of verbs) {
17
- parent
18
- .command(verb)
19
- .description(`Run the ${domain} ${verb} command`)
20
- .allowUnknownOption()
21
- .allowExcessArguments(true)
22
- .passThroughOptions()
23
- .helpOption(false)
24
- .action(async (_opts: unknown, cmd: Command) => {
25
- if (!isHelpRequest(cmd.args)) intro(`canon ${domain}`)
26
- await execScript(`${domain}/${verb}.sh`, cmd.args)
27
- })
28
- }
29
- }
30
-
31
- /**
32
- * Commander resolves `--help` before an action runs, so a pass-through verb
33
- * that keeps the built-in help option prints a one-line stub instead of the
34
- * script's usage. Disabling it lets the flag reach the script, which owns the
35
- * real flag surface, and this guard keeps the frame from opening around it.
36
- */
37
- function isHelpRequest(args: readonly string[]): boolean {
38
- return args.includes('-h') || args.includes('--help')
39
- }
@@ -1,142 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- set -o pipefail
4
-
5
- GREEN='\033[0;32m'
6
- RED='\033[0;31m'
7
- WHITE='\033[1;37m'
8
- GREY='\033[0;90m'
9
- NC='\033[0m'
10
-
11
- NESTED="${VERIFY_NESTED:-false}"
12
-
13
- log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
14
- log_error() {
15
- echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
16
- exit 1
17
- }
18
- log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
19
- log_skip() { echo -e "${GREY}│ - $1${NC}"; }
20
-
21
- pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
22
-
23
- check_dependencies() {
24
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
25
- command -v go >/dev/null 2>&1 || log_error "go is not installed"
26
- command -v golangci-lint >/dev/null 2>&1 || log_error "golangci-lint is not installed. Run 'go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'"
27
- }
28
-
29
- check_package() {
30
- [ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
31
- }
32
-
33
- has_script() {
34
- bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
35
- }
36
-
37
- # A subfolder synced with --skip base declares none of base's scripts, since
38
- # the repository root owns them. Each base phase runs only where its script is
39
- # declared, so this script passes on its own in that subfolder.
40
- run_base_phase() {
41
- local title=$1
42
- local script=$2
43
- local err_msg=$3
44
- local pass_msg=$4
45
-
46
- log_step "$title"
47
- if ! has_script "$script"; then
48
- log_skip "Skipped: $script is not declared here, so the repository root runs it"
49
- return
50
- fi
51
- run_check "bun run $script" "$err_msg"
52
- log_info "$pass_msg"
53
- }
54
-
55
- check_markdown_bans() {
56
- if ! command -v canon >/dev/null 2>&1; then
57
- log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
58
- return 0
59
- fi
60
-
61
- local files=()
62
- while IFS= read -r file; do
63
- files+=("$file")
64
- done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
65
-
66
- if [ ${#files[@]} -eq 0 ]; then
67
- log_info "No markdown file to check outside the exclusion set."
68
- return 0
69
- fi
70
-
71
- local output code
72
- if output=$(canon markdown audit "${files[@]}" 2>&1); then
73
- code=0
74
- else
75
- code=$?
76
- fi
77
-
78
- case "$code" in
79
- 0)
80
- log_info "No banned character"
81
- ;;
82
- 1)
83
- log_info "Skipped: canon markdown audit refused and measured nothing."
84
- ;;
85
- 2)
86
- echo "$output" | pipe_output
87
- log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
88
- ;;
89
- 3)
90
- echo "$output" | pipe_output
91
- log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
92
- ;;
93
- *)
94
- log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
95
- ;;
96
- esac
97
- }
98
-
99
- run_check() {
100
- local cmd=$1
101
- local err_msg=$2
102
- local output
103
- if ! output=$(eval "$cmd" 2>&1); then
104
- echo "$output" | pipe_output
105
- log_error "$err_msg"
106
- fi
107
- echo "$output" | pipe_output
108
- }
109
-
110
- main() {
111
- check_dependencies
112
- check_package
113
-
114
- if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
115
-
116
- echo -e "${GREY}├${NC} ${WHITE}Typecheck${NC}"
117
- run_check "bun run typecheck" "Typecheck failed"
118
- log_info "Typecheck passed"
119
-
120
- log_step "Lint"
121
- run_check "bun run lint" "Lint failed"
122
- log_info "Lint passed"
123
-
124
- run_base_phase "Formatting" "format" "Format failed" "Format applied"
125
- run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
126
- run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
127
- run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
128
-
129
- log_step "Unit tests"
130
- run_check "bun run test:run" "Unit tests failed"
131
- log_info "Unit tests passed"
132
-
133
- log_step "Markdown bans"
134
- check_markdown_bans
135
-
136
- if [ "$NESTED" = false ]; then
137
- echo -e "${GREY}└${NC}\n"
138
- echo -e "${GREEN}✓ Verification passed${NC}"
139
- fi
140
- }
141
-
142
- main "$@"
@@ -1,147 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- set -o pipefail
4
-
5
- GREEN='\033[0;32m'
6
- RED='\033[0;31m'
7
- WHITE='\033[1;37m'
8
- GREY='\033[0;90m'
9
- NC='\033[0m'
10
-
11
- NESTED="${VERIFY_NESTED:-false}"
12
-
13
- log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
14
- log_error() {
15
- echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
16
- exit 1
17
- }
18
- log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
19
- log_skip() { echo -e "${GREY}│ - $1${NC}"; }
20
-
21
- pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
22
-
23
- check_dependencies() {
24
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
25
- command -v php >/dev/null 2>&1 || log_error "php is not installed"
26
- command -v composer >/dev/null 2>&1 || log_error "composer is not installed"
27
- }
28
-
29
- check_package() {
30
- [ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
31
- }
32
-
33
- check_vendor() {
34
- [ -x vendor/bin/phpunit ] || log_error "No vendor/bin/phpunit here. Run 'composer install'"
35
- }
36
-
37
- has_script() {
38
- bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
39
- }
40
-
41
- # A subfolder synced with --skip base declares none of base's scripts, since
42
- # the repository root owns them. Each base phase runs only where its script is
43
- # declared, so this script passes on its own in that subfolder.
44
- run_base_phase() {
45
- local title=$1
46
- local script=$2
47
- local err_msg=$3
48
- local pass_msg=$4
49
-
50
- log_step "$title"
51
- if ! has_script "$script"; then
52
- log_skip "Skipped: $script is not declared here, so the repository root runs it"
53
- return
54
- fi
55
- run_check "bun run $script" "$err_msg"
56
- log_info "$pass_msg"
57
- }
58
-
59
- check_markdown_bans() {
60
- if ! command -v canon >/dev/null 2>&1; then
61
- log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
62
- return 0
63
- fi
64
-
65
- local files=()
66
- while IFS= read -r file; do
67
- files+=("$file")
68
- done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
69
-
70
- if [ ${#files[@]} -eq 0 ]; then
71
- log_info "No markdown file to check outside the exclusion set."
72
- return 0
73
- fi
74
-
75
- local output code
76
- if output=$(canon markdown audit "${files[@]}" 2>&1); then
77
- code=0
78
- else
79
- code=$?
80
- fi
81
-
82
- case "$code" in
83
- 0)
84
- log_info "No banned character"
85
- ;;
86
- 1)
87
- log_info "Skipped: canon markdown audit refused and measured nothing."
88
- ;;
89
- 2)
90
- echo "$output" | pipe_output
91
- log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
92
- ;;
93
- 3)
94
- echo "$output" | pipe_output
95
- log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
96
- ;;
97
- *)
98
- log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
99
- ;;
100
- esac
101
- }
102
-
103
- run_check() {
104
- local cmd=$1
105
- local err_msg=$2
106
- local output
107
- if ! output=$(eval "$cmd" 2>&1); then
108
- echo "$output" | pipe_output
109
- log_error "$err_msg"
110
- fi
111
- echo "$output" | pipe_output
112
- }
113
-
114
- main() {
115
- check_dependencies
116
- check_package
117
- check_vendor
118
-
119
- if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
120
-
121
- echo -e "${GREY}├${NC} ${WHITE}Typecheck${NC}"
122
- run_check "bun run typecheck" "Typecheck failed"
123
- log_info "Typecheck passed"
124
-
125
- log_step "Lint"
126
- run_check "bun run lint" "Lint failed"
127
- log_info "Lint passed"
128
-
129
- run_base_phase "Formatting" "format" "Format failed" "Format applied"
130
- run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
131
- run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
132
- run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
133
-
134
- log_step "Unit tests"
135
- run_check "bun run test:run" "Unit tests failed"
136
- log_info "Unit tests passed"
137
-
138
- log_step "Markdown bans"
139
- check_markdown_bans
140
-
141
- if [ "$NESTED" = false ]; then
142
- echo -e "${GREY}└${NC}\n"
143
- echo -e "${GREEN}✓ Verification passed${NC}"
144
- fi
145
- }
146
-
147
- main "$@"