specpro-cli 0.1.0__py3-none-any.whl
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.
- specpro_cli/__init__.py +16 -0
- specpro_cli/assets/commands/specpro.analyze.md +1102 -0
- specpro_cli/assets/commands/specpro.checklist.md +335 -0
- specpro_cli/assets/commands/specpro.clarify.md +581 -0
- specpro_cli/assets/commands/specpro.constitution.md +488 -0
- specpro_cli/assets/commands/specpro.feature.md +115 -0
- specpro_cli/assets/commands/specpro.implement.md +1881 -0
- specpro_cli/assets/commands/specpro.manual-test.md +206 -0
- specpro_cli/assets/commands/specpro.plan.md +3284 -0
- specpro_cli/assets/commands/specpro.qc.md +1489 -0
- specpro_cli/assets/commands/specpro.scenarios.md +154 -0
- specpro_cli/assets/commands/specpro.specify.md +1449 -0
- specpro_cli/assets/commands/specpro.status.md +863 -0
- specpro_cli/assets/commands/specpro.tasks.md +1207 -0
- specpro_cli/assets/commands/specpro.test-implement.md +462 -0
- specpro_cli/assets/commands/specpro.test-plan.md +383 -0
- specpro_cli/assets/commands/specpro.user-manual.md +178 -0
- specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
- specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
- specpro_cli/assets/scripts/bash/common.sh +88 -0
- specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
- specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
- specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
- specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
- specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
- specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
- specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
- specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
- specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
- specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
- specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
- specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
- specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
- specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
- specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
- specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
- specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
- specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
- specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
- specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
- specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
- specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
- specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
- specpro_cli/assets/templates/agent-context-template.md +49 -0
- specpro_cli/assets/templates/assumptions-template.md +248 -0
- specpro_cli/assets/templates/checklist-template.md +40 -0
- specpro_cli/assets/templates/clarifications-template.md +155 -0
- specpro_cli/assets/templates/constitution-template.md +50 -0
- specpro_cli/assets/templates/feature-spec-template.md +66 -0
- specpro_cli/assets/templates/plan-overview-template.md +150 -0
- specpro_cli/assets/templates/plan-template.md +387 -0
- specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
- specpro_cli/assets/templates/requirements-template.md +356 -0
- specpro_cli/assets/templates/spec-template.md +267 -0
- specpro_cli/assets/templates/tasks-template.md +252 -0
- specpro_cli/assets/templates/test-tasks-template.md +174 -0
- specpro_cli/cli/__init__.py +5 -0
- specpro_cli/cli/cmd_init.py +416 -0
- specpro_cli/cli/cmd_remove.py +122 -0
- specpro_cli/cli/entry.py +181 -0
- specpro_cli/integrations/__init__.py +36 -0
- specpro_cli/integrations/base.py +601 -0
- specpro_cli/integrations/claude/__init__.py +101 -0
- specpro_cli/integrations/copilot/__init__.py +153 -0
- specpro_cli/integrations/cursor_agent/__init__.py +51 -0
- specpro_cli/integrations/gemini/__init__.py +44 -0
- specpro_cli/integrations/opencode/__init__.py +48 -0
- specpro_cli/integrations/qodercli/__init__.py +54 -0
- specpro_cli/integrations/registry.py +88 -0
- specpro_cli/packaged/__init__.py +5 -0
- specpro_cli/packaged/sync.py +106 -0
- specpro_cli-0.1.0.dist-info/METADATA +117 -0
- specpro_cli-0.1.0.dist-info/RECORD +76 -0
- specpro_cli-0.1.0.dist-info/WHEEL +4 -0
- specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
- specpro_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Cumulative-record verifier — a cross-round record must not live in an artifact that
|
|
4
|
+
# some command REBUILDS WHOLESALE.
|
|
5
|
+
#
|
|
6
|
+
# Why this exists (plan.md → Constraint 11, FR-025): a record whose members accumulate
|
|
7
|
+
# round over round derives its value from surviving. Put it in a container that a command
|
|
8
|
+
# replaces wholesale and the accumulation is **silently destroyed** — the rebuild is a
|
|
9
|
+
# successful `cp`, so every exit code in the chain is 0.
|
|
10
|
+
#
|
|
11
|
+
# ⚠️ **Measured, not hypothetical** (2026-09-19): the tool-defect baseline lived at the end
|
|
12
|
+
# of `specs/checklists/requirements.md`, which `/specpro-qc` copies from its template on
|
|
13
|
+
# every run. One run took that section from **10 rows → 0 rows** and the file from
|
|
14
|
+
# **47086 B → 13216 B**, with no error anywhere. The list survived that long because the
|
|
15
|
+
# deletion happened to go unnoticed.
|
|
16
|
+
#
|
|
17
|
+
# ⚠️ **The two facts that must both hold look contradictory and are not**: the quality
|
|
18
|
+
# checklist MUST be rebuilt every run (its value is *this* run's verdict — FR-060), and the
|
|
19
|
+
# regression baseline MUST accumulate (its value is the defects found across runs — FR-025).
|
|
20
|
+
# ⇒ They must be **two artifacts**. Merging them uses one's property to destroy the other's,
|
|
21
|
+
# which is exactly the measured shape above.
|
|
22
|
+
#
|
|
23
|
+
# ⚠️ **Both sides are DERIVED — there is no registry, and that is a design decision.**
|
|
24
|
+
# An earlier version kept two hand-written registries (records; rebuild sites) *inside this
|
|
25
|
+
# script*. That is not just a list that fails by omission: it makes the verifier
|
|
26
|
+
# **unfixtureable**. A fixture would have to edit the script to plant a violation, and the
|
|
27
|
+
# PowerShell twin under test is the one in the repository while the tree holds its own copy —
|
|
28
|
+
# so an edit to one side's registry breaks parity, and the fixture can never be written.
|
|
29
|
+
# ⇒ **The subject declares itself where it lives**:
|
|
30
|
+
# · a cross-round record says so in its own header: `**Cumulative**: …`
|
|
31
|
+
# · a wholesale rebuild is a `cp … "$VAR"` in `commands/`, which is what that act
|
|
32
|
+
# looks like here (an ordinary `cp .env.example .env` inside a quickstart example
|
|
33
|
+
# is not a rebuild — the destination is not a variable)
|
|
34
|
+
# Fixtures are then plain data edits to the tree, and both twins read the same data.
|
|
35
|
+
#
|
|
36
|
+
# Usage: scripts/bash/verify-cumulative-records.sh
|
|
37
|
+
# RECORDS_HOOK_BASE=<path> scripts/bash/verify-cumulative-records.sh (fixtures)
|
|
38
|
+
# Exit: 0 = no record sits on a rebuild path; 1 = at least one violation
|
|
39
|
+
|
|
40
|
+
# ⚠️ NOT `set -e`: this script reads greps that are *expected* to miss.
|
|
41
|
+
set -uo pipefail
|
|
42
|
+
|
|
43
|
+
is_repo_root() { [ -d "$1/commands" ] && [ -d "$1/templates" ] && [ -d "$1/scripts" ]; }
|
|
44
|
+
|
|
45
|
+
# Base resolution mirrors `verify-deployment.sh` (T180 / ISS-124): position-independent, and
|
|
46
|
+
# the RESULT is tested rather than the method — the same source resolves correctly from the
|
|
47
|
+
# repository root and from the deployed mirror, which sit at different depths.
|
|
48
|
+
resolve_base() {
|
|
49
|
+
local cand here
|
|
50
|
+
if cand=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$cand" ] && is_repo_root "$cand"; then
|
|
51
|
+
printf '%s' "$cand"; return 0
|
|
52
|
+
fi
|
|
53
|
+
here=$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
54
|
+
for cand in "$here/../.." "$here/../../.."; do
|
|
55
|
+
cand=$(CDPATH="" cd "$cand" 2>/dev/null && pwd) || continue
|
|
56
|
+
is_repo_root "$cand" && { printf '%s' "$cand"; return 0; }
|
|
57
|
+
done
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
BASE="${RECORDS_HOOK_BASE:-}"
|
|
62
|
+
if [ -n "$BASE" ]; then
|
|
63
|
+
BASE=$(CDPATH="" cd "$BASE" 2>/dev/null && pwd) || { echo "✗ RECORDS_HOOK_BASE does not resolve: $RECORDS_HOOK_BASE"; exit 1; }
|
|
64
|
+
is_repo_root "$BASE" || { echo "✗ RECORDS_HOOK_BASE is not a repository root: $BASE"; exit 1; }
|
|
65
|
+
else
|
|
66
|
+
BASE=$(resolve_base) || { echo "✗ cannot locate the repository root"; exit 1; }
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
HITS=0
|
|
70
|
+
ok() { echo " ✓ $1"; }
|
|
71
|
+
hit() { echo " ✗ $1"; HITS=$((HITS + 1)); }
|
|
72
|
+
skip() { echo " ⊘ $1"; }
|
|
73
|
+
|
|
74
|
+
echo "Cumulative-record check: $BASE"
|
|
75
|
+
|
|
76
|
+
# -----------------------------------------------------------------------------------------
|
|
77
|
+
# 1. Records — every file under specs/ that declares itself cumulative
|
|
78
|
+
# -----------------------------------------------------------------------------------------
|
|
79
|
+
echo " [1] cross-round records (self-declared: \`**Cumulative**: …\`)"
|
|
80
|
+
RECORDS=""
|
|
81
|
+
if [ -d "$BASE/specs" ]; then
|
|
82
|
+
RECORDS=$(grep -rlE '^\*\*Cumulative\*\*: ' "$BASE/specs" --include='*.md' 2>/dev/null \
|
|
83
|
+
| sed "s|^$BASE/||" | LC_ALL=C sort || true)
|
|
84
|
+
fi
|
|
85
|
+
if [ -z "$RECORDS" ]; then
|
|
86
|
+
# ⚠️ A SKIP, not a hit: this script ships with the tool, and a user project may legitimately
|
|
87
|
+
# have no cross-round record at all. ⚠️ The cost is real and stated: **un-declaring a record
|
|
88
|
+
# turns this check off for it** — which is why the declaration lives in the record's own
|
|
89
|
+
# header, where removing it is a visible edit to the record itself.
|
|
90
|
+
skip "no file under specs/ declares itself cumulative — nothing is proven"
|
|
91
|
+
else
|
|
92
|
+
while IFS= read -r r; do
|
|
93
|
+
[ -n "$r" ] || continue
|
|
94
|
+
ok "record: $r"
|
|
95
|
+
done <<EOF
|
|
96
|
+
$RECORDS
|
|
97
|
+
EOF
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# -----------------------------------------------------------------------------------------
|
|
101
|
+
# 2. Rebuild sites — a `cp` in commands/ whose destination is a shell variable
|
|
102
|
+
# -----------------------------------------------------------------------------------------
|
|
103
|
+
# ⚠️ The optional closing quote is load-bearing. The destination is written `"$VAR"`, so a
|
|
104
|
+
# pattern anchored on the variable running bare to end-of-line finds **nothing** and this
|
|
105
|
+
# check reports a clean skip while seeing no subject at all — a detection that cannot see
|
|
106
|
+
# its own subject, which is the shape it exists to catch. Measured: the first version had
|
|
107
|
+
# exactly that bug.
|
|
108
|
+
REBUILD_RE='^[[:space:]]*cp[[:space:]]+.*\$[A-Za-z_][A-Za-z0-9_]*["'"'"']?[[:space:]]*$'
|
|
109
|
+
echo " [2] wholesale-rebuild sites (\`cp\` to a shell variable in commands/)"
|
|
110
|
+
SITES=""
|
|
111
|
+
if [ -d "$BASE/commands" ]; then
|
|
112
|
+
SITES=$(grep -nH -E "$REBUILD_RE" "$BASE"/commands/*.md 2>/dev/null || true)
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# The destination is a variable; resolve it to a path SUFFIX by reading that variable's
|
|
116
|
+
# binding **in the same document** and dropping everything up to and including the last
|
|
117
|
+
# `$VAR/` (then the leading variable name, by cutting at the first `/`). Only the suffix is
|
|
118
|
+
# compared, so the binding's own base variable (`$SPECS_DIR`, `$REPO`) never has to be
|
|
119
|
+
# resolved — the question is only "does the record live under there?".
|
|
120
|
+
dest_suffix() {
|
|
121
|
+
local doc=$1 var=$2 binding
|
|
122
|
+
binding=$(grep -oE "^[[:space:]]*${var}=\"[^\"]*\"" "$BASE/$doc" 2>/dev/null | head -1)
|
|
123
|
+
[ -n "$binding" ] || { printf ''; return; }
|
|
124
|
+
binding=${binding#*=\"}
|
|
125
|
+
binding=${binding%\"}
|
|
126
|
+
binding=${binding##*\$}
|
|
127
|
+
printf '%s' "${binding#*/}"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
SITE_N=0
|
|
131
|
+
while IFS= read -r entry; do
|
|
132
|
+
[ -n "$entry" ] || continue
|
|
133
|
+
SITE_N=$((SITE_N + 1))
|
|
134
|
+
# `grep -nH` emits `path:line:content` — split three ways; comparing the whole remainder
|
|
135
|
+
# would carry the line number and never match anything.
|
|
136
|
+
file=${entry%%:*}
|
|
137
|
+
rest=${entry#*:}
|
|
138
|
+
num=${rest%%:*}
|
|
139
|
+
text=${rest#*:}
|
|
140
|
+
text=${text#"${text%%[![:space:]]*}"}
|
|
141
|
+
rel=${file#"$BASE"/}
|
|
142
|
+
var=$(printf '%s' "$text" | grep -oE '\$[A-Za-z_][A-Za-z0-9_]*' | tail -1 | tr -d '$')
|
|
143
|
+
suffix=$(dest_suffix "$rel" "$var")
|
|
144
|
+
if [ -z "$suffix" ]; then
|
|
145
|
+
hit "rebuild site: cannot resolve \$$var in $rel:$num — nothing is proven about it"
|
|
146
|
+
continue
|
|
147
|
+
fi
|
|
148
|
+
ok "rebuild site: $rel:$num → $suffix"
|
|
149
|
+
done <<EOF
|
|
150
|
+
$SITES
|
|
151
|
+
EOF
|
|
152
|
+
if [ "$SITE_N" -eq 0 ]; then
|
|
153
|
+
skip "no \`cp … \$VAR\` line found in commands/ — nothing is proven about rebuild sites"
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# -----------------------------------------------------------------------------------------
|
|
157
|
+
# 3. The proposition: no record sits where a rebuild lands
|
|
158
|
+
# -----------------------------------------------------------------------------------------
|
|
159
|
+
echo " [3] no cross-round record sits on a rebuild path"
|
|
160
|
+
CHECKED=0
|
|
161
|
+
while IFS= read -r entry; do
|
|
162
|
+
[ -n "$entry" ] || continue
|
|
163
|
+
file=${entry%%:*}
|
|
164
|
+
rest=${entry#*:}
|
|
165
|
+
text=${rest#*:}
|
|
166
|
+
text=${text#"${text%%[![:space:]]*}"}
|
|
167
|
+
rel=${file#"$BASE"/}
|
|
168
|
+
var=$(printf '%s' "$text" | grep -oE '\$[A-Za-z_][A-Za-z0-9_]*' | tail -1 | tr -d '$')
|
|
169
|
+
suffix=$(dest_suffix "$rel" "$var")
|
|
170
|
+
[ -n "$suffix" ] || continue
|
|
171
|
+
while IFS= read -r r; do
|
|
172
|
+
[ -n "$r" ] || continue
|
|
173
|
+
CHECKED=$((CHECKED + 1))
|
|
174
|
+
case "$r" in
|
|
175
|
+
*/"$suffix"|"$suffix")
|
|
176
|
+
hit "a cross-round record lives in an artifact rebuilt wholesale — $r (rebuilt by $rel)" ;;
|
|
177
|
+
*)
|
|
178
|
+
ok "not on a rebuild path: $r" ;;
|
|
179
|
+
esac
|
|
180
|
+
done <<EOF
|
|
181
|
+
$RECORDS
|
|
182
|
+
EOF
|
|
183
|
+
done <<EOF
|
|
184
|
+
$SITES
|
|
185
|
+
EOF
|
|
186
|
+
if [ "$CHECKED" -eq 0 ]; then
|
|
187
|
+
skip "no (record × rebuild site) pair to compare — nothing is proven"
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# -----------------------------------------------------------------------------------------
|
|
191
|
+
# ⚠️ Coverage, stated rather than implied: the check is only as wide as its two derivations.
|
|
192
|
+
# · a record that does not declare itself is invisible here (see check 1)
|
|
193
|
+
# · a rebuild that is not `cp`-based, or whose destination is not a variable, is invisible
|
|
194
|
+
# here — e.g. a command that rewrites a file through `awk`/`sed` in place.
|
|
195
|
+
# Both are the "enumeration narrower than the distribution" shape. Widening either means
|
|
196
|
+
# widening the derivation, never adding a hand-written row.
|
|
197
|
+
# -----------------------------------------------------------------------------------------
|
|
198
|
+
echo
|
|
199
|
+
if [ "$HITS" -gt 0 ]; then
|
|
200
|
+
echo "✗ cumulative records: $HITS violation(s)"
|
|
201
|
+
exit 1
|
|
202
|
+
fi
|
|
203
|
+
echo "✓ cumulative records: no record sits on a rebuild path"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Deliverables-tracked verifier — every deliverable path must be tracked by git.
|
|
4
|
+
#
|
|
5
|
+
# Carrier for: specs/tasks.md -> T150 (carrying ISS-56 ②). The criterion is stated
|
|
6
|
+
# there as: `git ls-files --error-unmatch <path>` holds for EVERY deliverable path.
|
|
7
|
+
#
|
|
8
|
+
# The three deliverable classes it checks (same task):
|
|
9
|
+
# ① paths named by the Location of each CHECKED task in specs/tasks.md
|
|
10
|
+
# ② source files under commands/ · templates/ · scripts/ (+ tools/ in this repo)
|
|
11
|
+
# ③ artifacts declared by the commands' `writes:` blocks — they land in the
|
|
12
|
+
# artifact repos (specs/ · docs/), so those repos are swept whole
|
|
13
|
+
#
|
|
14
|
+
# ⚠️ SCOPE IS THE SUBJECT, NOT THE WHOLE REPO (ISS-75): the old wording counted
|
|
15
|
+
# ALL untracked-unignored files, which swept in root-level session transcripts
|
|
16
|
+
# (handoff*.md) that are NOT deliverables. A criterion failing on non-subjects
|
|
17
|
+
# invites "fix the criterion to match reality" — shape 1. So this verifier only
|
|
18
|
+
# asserts INSIDE the deliverable dirs and the artifact repos; a root-level
|
|
19
|
+
# transcript is out of scope by construction, not by exemption list.
|
|
20
|
+
#
|
|
21
|
+
# Class ① parsing is deliberately conservative: a backtick segment is checked
|
|
22
|
+
# only when it is unambiguously a path (no spaces, no globs). Under-reporting is
|
|
23
|
+
# accepted (the same discipline as the documented-snippet verifier); a false
|
|
24
|
+
# accusation is not — the contract's stated failure mode is losing credibility.
|
|
25
|
+
#
|
|
26
|
+
# Bash 3.2 compatible by construction — no 4.0+ expansions, no associative arrays.
|
|
27
|
+
# Read-only: nothing is written.
|
|
28
|
+
#
|
|
29
|
+
set -u
|
|
30
|
+
|
|
31
|
+
REPO=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
32
|
+
if [ -z "$REPO" ]; then
|
|
33
|
+
echo "deliverables-tracked: not inside a git repository"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
echo "deliverables-tracked: $REPO"
|
|
37
|
+
FAIL=0
|
|
38
|
+
|
|
39
|
+
# --- class ② — deliverable source dirs in THIS repo ------------------------
|
|
40
|
+
SRC_DIRS="commands templates scripts tools"
|
|
41
|
+
untracked=$(cd "$REPO" && git ls-files --others --exclude-standard -- $SRC_DIRS)
|
|
42
|
+
if [ -n "$untracked" ]; then
|
|
43
|
+
FAIL=1
|
|
44
|
+
echo "✗ class ② — untracked files under deliverable source dirs ($SRC_DIRS):"
|
|
45
|
+
printf '%s\n' "$untracked" | sed 's/^/ /'
|
|
46
|
+
else
|
|
47
|
+
echo "✓ class ② — source dirs ($SRC_DIRS): every file tracked"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# --- class ③ — artifact repos (specs/ · docs/) ------------------------------
|
|
51
|
+
for sub in specs docs; do
|
|
52
|
+
if [ -e "$REPO/$sub/.git" ]; then
|
|
53
|
+
u=$(git -C "$REPO/$sub" ls-files --others --exclude-standard)
|
|
54
|
+
if [ -n "$u" ]; then
|
|
55
|
+
FAIL=1
|
|
56
|
+
echo "✗ class ③ — untracked files in the $sub/ artifact repo:"
|
|
57
|
+
printf '%s\n' "$u" | sed 's/^/ /'
|
|
58
|
+
else
|
|
59
|
+
echo "✓ class ③ — artifact repo $sub/: every file tracked"
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
done
|
|
63
|
+
|
|
64
|
+
# --- class ① — Location paths named by CHECKED tasks ------------------------
|
|
65
|
+
#
|
|
66
|
+
# ⚠️ Judgment is THREE-CONDITIONED, and the reason is measured: backtick segments in
|
|
67
|
+
# a task line include descriptions' code words (function names, flags, FR ids),
|
|
68
|
+
# so "not tracked" alone accuses hundreds of non-paths — a false-accusation rate
|
|
69
|
+
# that would destroy the criterion's credibility on its first run. The verdict
|
|
70
|
+
# therefore needs the file to EXIST first, and (T254 / ISS-235) git to NOT be
|
|
71
|
+
# deliberately ignoring it:
|
|
72
|
+
# tracked -> verified
|
|
73
|
+
# untracked BUT on disk -> real violation (written, never committed)
|
|
74
|
+
# ignored BY DESIGN -> skipped (a derived deploy mirror — `.claude/`,
|
|
75
|
+
# `.specpro/` — is gitignored on purpose; the
|
|
76
|
+
# deliverable is its source side, covered by the
|
|
77
|
+
# same Location's other path)
|
|
78
|
+
# not on disk at all -> skipped (future deliverable / shorthand / note)
|
|
79
|
+
TASKS="$REPO/specs/tasks.md"
|
|
80
|
+
checked_n=0
|
|
81
|
+
skipped_n=0
|
|
82
|
+
if [ -f "$TASKS" ]; then
|
|
83
|
+
segments=$(sed -n 's/^- \[x\] T[0-9].*$/&/p' "$TASKS" | grep -oE '`[^`]+`' | tr -d '`')
|
|
84
|
+
while IFS= read -r seg; do
|
|
85
|
+
[ -z "$seg" ] && continue
|
|
86
|
+
case "$seg" in
|
|
87
|
+
*\ *) skipped_n=$((skipped_n + 1)); continue ;; # space -> prose/note
|
|
88
|
+
specs/*) repo_path="$REPO/specs"; rel=${seg#specs/} ;;
|
|
89
|
+
docs/*) repo_path="$REPO/docs"; rel=${seg#docs/} ;;
|
|
90
|
+
/*) skipped_n=$((skipped_n + 1)); continue ;; # absolute
|
|
91
|
+
*) repo_path="$REPO"; rel=$seg ;;
|
|
92
|
+
esac
|
|
93
|
+
# A segment that reduces to an empty path (`specs/` bare) is not a path —
|
|
94
|
+
# feeding it to git only produces a pathspec error on stderr (T254).
|
|
95
|
+
if [ -z "$rel" ]; then
|
|
96
|
+
skipped_n=$((skipped_n + 1))
|
|
97
|
+
continue
|
|
98
|
+
fi
|
|
99
|
+
# Brace expansion, one level: `x.{a,b}.md` -> prefix + each + suffix.
|
|
100
|
+
case "$rel" in
|
|
101
|
+
*\{*)
|
|
102
|
+
pre=${rel%%\{*}
|
|
103
|
+
tmp=${rel#*\{}
|
|
104
|
+
mid=${tmp%%\}*}
|
|
105
|
+
suf=${tmp#*\}}
|
|
106
|
+
for br in $(printf '%s' "$mid" | tr ',' ' '); do
|
|
107
|
+
one="$pre$br$suf"
|
|
108
|
+
if [ -z "$one" ]; then
|
|
109
|
+
skipped_n=$((skipped_n + 1))
|
|
110
|
+
continue
|
|
111
|
+
fi
|
|
112
|
+
if git -C "$repo_path" check-ignore -q -- "$one"; then
|
|
113
|
+
skipped_n=$((skipped_n + 1)) # ignored by design: derived mirror, not a deliverable
|
|
114
|
+
elif git -C "$repo_path" ls-files --error-unmatch -- "$one" >/dev/null 2>&1; then
|
|
115
|
+
checked_n=$((checked_n + 1))
|
|
116
|
+
elif [ -f "$repo_path/$one" ]; then
|
|
117
|
+
FAIL=1
|
|
118
|
+
echo "✗ class ① — task Location file exists but is NOT tracked: $seg (expanded: $one)"
|
|
119
|
+
else
|
|
120
|
+
skipped_n=$((skipped_n + 1))
|
|
121
|
+
fi
|
|
122
|
+
done
|
|
123
|
+
continue
|
|
124
|
+
;;
|
|
125
|
+
esac
|
|
126
|
+
if git -C "$repo_path" check-ignore -q -- "$rel"; then
|
|
127
|
+
skipped_n=$((skipped_n + 1)) # ignored by design: derived mirror, not a deliverable
|
|
128
|
+
elif git -C "$repo_path" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1; then
|
|
129
|
+
checked_n=$((checked_n + 1))
|
|
130
|
+
elif [ -f "$repo_path/$rel" ]; then
|
|
131
|
+
FAIL=1
|
|
132
|
+
echo "✗ class ① — task Location file exists but is NOT tracked: $seg"
|
|
133
|
+
else
|
|
134
|
+
skipped_n=$((skipped_n + 1))
|
|
135
|
+
fi
|
|
136
|
+
done <<EOF
|
|
137
|
+
$segments
|
|
138
|
+
EOF
|
|
139
|
+
echo "✓ class ① — task Locations: $checked_n path(s) verified tracked · $skipped_n skipped (not on disk / not a path / ignored-by-design)"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
if [ "$FAIL" -eq 0 ]; then
|
|
143
|
+
echo "✓ deliverables-tracked: every deliverable path is tracked"
|
|
144
|
+
exit 0
|
|
145
|
+
fi
|
|
146
|
+
echo "✗ deliverables-tracked: untracked deliverables found — commit them or fix the criterion"
|
|
147
|
+
exit 1
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Deployment-drift verifier — every mirror group must match its source.
|
|
4
|
+
#
|
|
5
|
+
# Why this exists: 宪法 Q1 requires that "一份内容在多个位置存在时,它们不得在无人
|
|
6
|
+
# 察觉的情况下分叉", and 宪法 1.1 requires that "命令与模板的改动与部署动作成对提交,
|
|
7
|
+
# 不留'已改源未部署'的中间态". Before this script, the only thing enforcing either was
|
|
8
|
+
# an instruction to run three `diff` commands by hand — and "记得跑" is not an enforcement
|
|
9
|
+
# point. The enforcement point is `scripts/git-hooks/pre-commit` §3, which calls this.
|
|
10
|
+
#
|
|
11
|
+
# 核验强度按目标形态确定 (plan.md → Constraint 1), and the form is judged FIRST:
|
|
12
|
+
# same — the mirror is a byte-for-byte copy ⇒ diff -rq, zero output expected
|
|
13
|
+
# derived — the mirror is produced by a rule (e.g. the skills layout) ⇒ "同一源 +
|
|
14
|
+
# 同一基类 ⇒ 同一产出" is the criterion, and **it is not implemented here**
|
|
15
|
+
#
|
|
16
|
+
# ⚠️ A group whose form is `derived` FAILS, loudly. That is deliberate: the alternative
|
|
17
|
+
# is to run a byte-comparison against a target that cannot be byte-equal, which reports
|
|
18
|
+
# failure for a correct deployment — or to skip it, which reports success for an
|
|
19
|
+
# unchecked one. A verifier that cannot check a group must say so, not pass it.
|
|
20
|
+
#
|
|
21
|
+
# ⚠️ A missing mirror FAILS too — never "skip". An absent group and a clean group are
|
|
22
|
+
# indistinguishable in the output otherwise, which is the 空跑报绿 shape (ISS-83).
|
|
23
|
+
#
|
|
24
|
+
# Usage: scripts/bash/verify-deployment.sh
|
|
25
|
+
# DEPLOY_HOOK_BASE=<path> scripts/bash/verify-deployment.sh (verify a copy)
|
|
26
|
+
# Exit: 0 = every group is in sync; 1 = at least one violation (details printed)
|
|
27
|
+
|
|
28
|
+
# ⚠️ NOT `set -e`: this script's logic is driven by reading the exit codes of `diff`, and
|
|
29
|
+
# `set -e` would end it before the reading happens (宪法 2.2.2, second carve-out — "其逻辑
|
|
30
|
+
# 以退出码为输入的核验脚本").
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
# The repository root is resolved from THIS script's location, not from the caller's cwd:
|
|
34
|
+
# the pre-commit hook runs from the repo root, but a fixture case runs the script from a
|
|
35
|
+
# temporary tree, and a cwd-relative base would then verify the wrong place (or nothing).
|
|
36
|
+
# `DEPLOY_HOOK_BASE` overrides it for exactly that fixture case.
|
|
37
|
+
#
|
|
38
|
+
# ⚠️ **Position-independent, and the result is TESTED** (T180 / `ISS-124`): the previous
|
|
39
|
+
# `dirname "${BASH_SOURCE[0]}"/../..` is correct only from the SOURCE layout — from the
|
|
40
|
+
# deployed mirror (`.specpro/scripts/bash/…`) it lands one level short, on `.specpro`.
|
|
41
|
+
# ⚠️ That failure is silent in the worst direction: a mirror table read against the
|
|
42
|
+
# wrong tree still prints "✓ in sync", indistinguishable from a real pass. And this
|
|
43
|
+
# script is the one the pre-commit hook calls, so the wrong tree is the likely one.
|
|
44
|
+
#
|
|
45
|
+
# Order: git (correct from BOTH positions) → the two candidate depths, each tested.
|
|
46
|
+
# The sentinel tests the RESULT, not the method — including for an override, since an
|
|
47
|
+
# override that names a non-root is exactly what it exists to catch.
|
|
48
|
+
is_repo_root() { [ -d "$1/commands" ] && [ -d "$1/templates" ] && [ -d "$1/scripts" ]; }
|
|
49
|
+
|
|
50
|
+
resolve_base() {
|
|
51
|
+
local cand here
|
|
52
|
+
if cand=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$cand" ] && is_repo_root "$cand"; then
|
|
53
|
+
printf '%s' "$cand"; return 0
|
|
54
|
+
fi
|
|
55
|
+
here=$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
56
|
+
for cand in "$here/../.." "$here/../../.."; do
|
|
57
|
+
cand=$(CDPATH="" cd "$cand" 2>/dev/null && pwd) || continue
|
|
58
|
+
is_repo_root "$cand" && { printf '%s' "$cand"; return 0; }
|
|
59
|
+
done
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if [ -n "${DEPLOY_HOOK_BASE:-}" ]; then
|
|
64
|
+
BASE="$DEPLOY_HOOK_BASE"
|
|
65
|
+
else
|
|
66
|
+
BASE=$(resolve_base) || BASE=''
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
if ! is_repo_root "$BASE"; then
|
|
70
|
+
echo "✗ BASE is not a repository root: '${BASE:-<unresolved>}'" >&2
|
|
71
|
+
echo " Expected it to contain commands/ , templates/ and scripts/ ." >&2
|
|
72
|
+
if [ -n "${DEPLOY_HOOK_BASE:-}" ]; then
|
|
73
|
+
echo " Source: the DEPLOY_HOOK_BASE override." >&2
|
|
74
|
+
else
|
|
75
|
+
echo " Source: auto-resolution (git toplevel, then the script's two possible depths)." >&2
|
|
76
|
+
fi
|
|
77
|
+
echo " ⚠️ Every group below would be compared against a tree this script never read —" >&2
|
|
78
|
+
echo " an absent mirror and an in-sync mirror are the same output otherwise." >&2
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# --- the mirror table ---------------------------------------------------------------
|
|
83
|
+
# 源 | 镜像 | 形态. One row per group. The form column is the authority for the check
|
|
84
|
+
# below — it is NOT guessed from the target's shape, because guessing turns a design
|
|
85
|
+
# decision into a filesystem accident.
|
|
86
|
+
PAIRS=(
|
|
87
|
+
"commands|.claude/skills|derived"
|
|
88
|
+
"templates|.specpro/templates|same"
|
|
89
|
+
"scripts/bash|.specpro/scripts/bash|same"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# --- the exclusion list, and why it is not a hidden default --------------------------
|
|
93
|
+
# `scripts/` entries with no mirror are listed HERE, by name, with a reason. A directory
|
|
94
|
+
# that is neither mirrored nor listed is a violation (§reverse below) — otherwise the
|
|
95
|
+
# next un-mirrored directory joins silently, which is the same silent-uncoverage the
|
|
96
|
+
# derived section list was removed to fix (ISS-88).
|
|
97
|
+
#
|
|
98
|
+
# ⚠️ `powershell` is here because the bootstrap instance does not need it: the mirror
|
|
99
|
+
# exists so THIS repository can run its own toolchain, and this repository is not Windows.
|
|
100
|
+
# The PowerShell implementations ship to user projects through the CLI's asset snapshot,
|
|
101
|
+
# which has its own verifier (Constraint 4). ⇒ It is excluded by DECISION, not by neglect.
|
|
102
|
+
EXCLUDED_IN_SCRIPTS="powershell git-hooks install-git-hooks.sh"
|
|
103
|
+
|
|
104
|
+
FAIL=0
|
|
105
|
+
echo "Deployment drift: $BASE"
|
|
106
|
+
echo
|
|
107
|
+
|
|
108
|
+
for pair in "${PAIRS[@]}"; do
|
|
109
|
+
src="${BASE}/${pair%%|*}"
|
|
110
|
+
rest="${pair#*|}"
|
|
111
|
+
dst="${BASE}/${rest%%|*}"
|
|
112
|
+
form="${rest##*|}"
|
|
113
|
+
|
|
114
|
+
printf " [%s] %s → %s\n" "$form" "${pair%%|*}" "${rest%%|*}"
|
|
115
|
+
|
|
116
|
+
if [ ! -e "$src" ]; then
|
|
117
|
+
echo " ✗ source does not exist: $src"
|
|
118
|
+
echo " → the table names a group that is not there; fix the table or restore the source."
|
|
119
|
+
FAIL=1
|
|
120
|
+
continue
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
# ⚠️ NOT `continue` / NOT `: ok`. A missing mirror is a violation, and saying so is
|
|
124
|
+
# the whole difference between a verifier and a decoration.
|
|
125
|
+
if [ ! -d "$dst" ]; then
|
|
126
|
+
echo " ✗ mirror missing: $dst"
|
|
127
|
+
echo " → deploy the source, or add the group to EXCLUDED_* by name with a reason."
|
|
128
|
+
echo " A missing target MUST NOT be skipped: an absent group and a clean one look identical."
|
|
129
|
+
FAIL=1
|
|
130
|
+
continue
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
if [ "$form" = "derived" ]; then
|
|
134
|
+
# Reproducible check (FR-007 / 宪法 1.1 T3): same source + same base class ⇒
|
|
135
|
+
# same output. The mirror is produced by THIS repo's install CLI, so the check
|
|
136
|
+
# runs that CLI twice from the CURRENT source into two temp dirs — the two
|
|
137
|
+
# outputs must agree byte-for-byte (determinism), and the mirror must equal the
|
|
138
|
+
# first (sync). A stale mirror therefore fails exactly like a drifted copy,
|
|
139
|
+
# which is the drift this gate exists to catch. Two installs, not one: a single
|
|
140
|
+
# install-vs-mirror comparison would silently accept a CLI that embeds
|
|
141
|
+
# timestamps or paths into its output.
|
|
142
|
+
#
|
|
143
|
+
# ⚠️ No installer ⇒ UNVERIFIED ⇒ FAIL. Never skip: an unchecked group that
|
|
144
|
+
# reports success is indistinguishable from a clean one.
|
|
145
|
+
py=""
|
|
146
|
+
for pycand in "$BASE/tools/install/.venv/bin/python" "$BASE/tools/install/.venv/Scripts/python.exe"; do
|
|
147
|
+
if [ -x "$pycand" ]; then py="$pycand"; break; fi
|
|
148
|
+
done
|
|
149
|
+
if [ -z "$py" ]; then
|
|
150
|
+
echo " ✗ derived check cannot run: no install CLI venv under tools/install/."
|
|
151
|
+
echo " → the derived mirror is produced by this repo's own CLI; without it this"
|
|
152
|
+
echo " group is UNVERIFIED, and reporting success would be a false pass."
|
|
153
|
+
FAIL=1
|
|
154
|
+
continue
|
|
155
|
+
fi
|
|
156
|
+
tmp1=$(mktemp -d "${TMPDIR:-/tmp}/specpro-derived.XXXXXX") || tmp1=""
|
|
157
|
+
tmp2=$(mktemp -d "${TMPDIR:-/tmp}/specpro-derived.XXXXXX") || tmp2=""
|
|
158
|
+
if [ -z "$tmp1" ] || [ -z "$tmp2" ]; then
|
|
159
|
+
echo " ✗ derived check cannot run: mktemp failed."
|
|
160
|
+
FAIL=1
|
|
161
|
+
continue
|
|
162
|
+
fi
|
|
163
|
+
inst_ok=1
|
|
164
|
+
( cd "$BASE/tools/install" && "$py" -c "from specpro_cli.cli.entry import app; app()" init "$tmp1" --agent claude --source "$BASE" >/dev/null 2>&1 ) || inst_ok=0
|
|
165
|
+
( cd "$BASE/tools/install" && "$py" -c "from specpro_cli.cli.entry import app; app()" init "$tmp2" --agent claude --source "$BASE" >/dev/null 2>&1 ) || inst_ok=0
|
|
166
|
+
if [ "$inst_ok" = 0 ] || [ ! -d "$tmp1/.claude/skills" ] || [ ! -d "$tmp2/.claude/skills" ]; then
|
|
167
|
+
echo " ✗ derived check cannot run: the install CLI failed to produce .claude/skills."
|
|
168
|
+
echo " → fix the CLI or its venv; until then this group is UNVERIFIED (never a silent pass)."
|
|
169
|
+
FAIL=1
|
|
170
|
+
rm -rf "$tmp1" "$tmp2"
|
|
171
|
+
continue
|
|
172
|
+
fi
|
|
173
|
+
if [ -n "$(diff -rq -x '.DS_Store' "$tmp1/.claude/skills" "$tmp2/.claude/skills" 2>&1)" ]; then
|
|
174
|
+
echo " ✗ derived: two installs of the same source DISAGREE — output is not reproducible."
|
|
175
|
+
rm -rf "$tmp1" "$tmp2"
|
|
176
|
+
FAIL=1
|
|
177
|
+
continue
|
|
178
|
+
fi
|
|
179
|
+
drift=$(diff -rq -x '.DS_Store' "$tmp1/.claude/skills" "$dst" 2>&1)
|
|
180
|
+
# Normalize the temp prefix away: the drift lines must not depend on where the
|
|
181
|
+
# fresh install landed (bash and the PowerShell twin resolve different temp
|
|
182
|
+
# roots, and the parity contract is "same stdout").
|
|
183
|
+
drift=${drift//"$tmp1"/'<fresh-install>'}
|
|
184
|
+
rm -rf "$tmp1" "$tmp2"
|
|
185
|
+
if [ -n "$drift" ]; then
|
|
186
|
+
echo " ✗ derived drift (mirror ≠ fresh install of the current source):"
|
|
187
|
+
echo "$drift" | sed 's/^/ /'
|
|
188
|
+
FAIL=1
|
|
189
|
+
else
|
|
190
|
+
echo " ✓ derived: reproducible (2 installs agree) and in sync with the current source"
|
|
191
|
+
fi
|
|
192
|
+
continue
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
drift=$(diff -rq -x '.DS_Store' "$src" "$dst" 2>&1)
|
|
196
|
+
if [ -n "$drift" ]; then
|
|
197
|
+
echo " ✗ drift:"
|
|
198
|
+
echo "$drift" | sed 's/^/ /'
|
|
199
|
+
FAIL=1
|
|
200
|
+
else
|
|
201
|
+
echo " ✓ byte-identical"
|
|
202
|
+
fi
|
|
203
|
+
done
|
|
204
|
+
|
|
205
|
+
# --- reverse assertion: nothing under scripts/ is silently unmirrored -----------------
|
|
206
|
+
echo
|
|
207
|
+
if [ -d "$BASE/scripts" ]; then
|
|
208
|
+
uncovered=""
|
|
209
|
+
for entry in "$BASE/scripts"/*; do
|
|
210
|
+
[ -e "$entry" ] || continue
|
|
211
|
+
name=$(basename "$entry")
|
|
212
|
+
case " $EXCLUDED_IN_SCRIPTS " in *" $name "*) continue ;; esac
|
|
213
|
+
covered=0
|
|
214
|
+
for pair in "${PAIRS[@]}"; do
|
|
215
|
+
case "${pair%%|*}" in "scripts/$name"*) covered=1 ;; esac
|
|
216
|
+
done
|
|
217
|
+
[ "$covered" = 0 ] && uncovered="$uncovered $name"
|
|
218
|
+
done
|
|
219
|
+
if [ -n "$uncovered" ]; then
|
|
220
|
+
echo " ✗ scripts/ entries that are neither mirrored nor listed as excluded:"
|
|
221
|
+
printf ' %s\n' $uncovered
|
|
222
|
+
echo " → add a mirror row, or add the name to EXCLUDED_IN_SCRIPTS with a reason."
|
|
223
|
+
echo " Otherwise the next un-mirrored directory joins without anyone noticing."
|
|
224
|
+
FAIL=1
|
|
225
|
+
else
|
|
226
|
+
echo " ✓ every scripts/ entry is either mirrored or listed as excluded"
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
# --- verdict --------------------------------------------------------------------------
|
|
231
|
+
echo
|
|
232
|
+
if [ "$FAIL" = 0 ]; then
|
|
233
|
+
echo "✓ deployment: every group is in sync"
|
|
234
|
+
exit 0
|
|
235
|
+
else
|
|
236
|
+
echo "✗ deployment: VIOLATIONS FOUND (see above)"
|
|
237
|
+
echo " → 宪法 1.1: 部署是该次落地的收尾动作 —— 源与镜像的差异不得跨提交存在。"
|
|
238
|
+
exit 1
|
|
239
|
+
fi
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Frontmatter-YAML verifier — every frontmatter block in commands/*.md MUST parse
|
|
4
|
+
# as YAML. Carrier for: specs/tasks.md -> T152 (carrying ISS-59 ① · ISS-81).
|
|
5
|
+
#
|
|
6
|
+
# Why this exists: CLAUDE.md states the command topology is NOT prose but the
|
|
7
|
+
# machine-readable `handoffs:` declarations — and the `writes:` ownership map
|
|
8
|
+
# lives in the same frontmatter block. A frontmatter block that fails to parse
|
|
9
|
+
# and a block that does not exist are INDISTINGUISHABLE to every executor
|
|
10
|
+
# (shape 3: two states no mechanism tells apart, and nothing reports it).
|
|
11
|
+
# The triggering instance was one unquoted colon in a `description:` — fixed
|
|
12
|
+
# by hand, leaving "nothing checks the next one". This script is the check
|
|
13
|
+
# for the next one.
|
|
14
|
+
#
|
|
15
|
+
# Scope (ISS-81 ruling, narrowing an over-broad original): "凡有 frontmatter
|
|
16
|
+
# 的产物都必须可解析" — templates/ carry no frontmatter and are OUT of the scan
|
|
17
|
+
# face. The deployed mirror .claude/skills/ (derived form since T090) is generated
|
|
18
|
+
# from commands/ by the install CLI and held by the deployment verifier, so scanning
|
|
19
|
+
# the source side covers both.
|
|
20
|
+
#
|
|
21
|
+
# Bash 3.2 compatible. Read-only. Requires python3 with PyYAML (the install CLI
|
|
22
|
+
# is a Python package — a maintained checkout has both).
|
|
23
|
+
#
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
REPO=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
27
|
+
if [ -z "$REPO" ]; then
|
|
28
|
+
echo "frontmatter-yaml: not inside a git repository"
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
COMMANDS_DIR="$REPO/commands"
|
|
33
|
+
if [ ! -d "$COMMANDS_DIR" ]; then
|
|
34
|
+
echo "frontmatter-yaml: no commands/ directory at $REPO — nothing to scan"
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
python3 - "$COMMANDS_DIR" <<'PY'
|
|
39
|
+
import glob
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
import yaml
|
|
44
|
+
|
|
45
|
+
d = sys.argv[1]
|
|
46
|
+
parsed = 0
|
|
47
|
+
bad = []
|
|
48
|
+
for f in sorted(glob.glob(os.path.join(d, '*.md'))):
|
|
49
|
+
with open(f, encoding='utf-8') as fh:
|
|
50
|
+
t = fh.read()
|
|
51
|
+
if not t.startswith('---'):
|
|
52
|
+
continue # "凡有 frontmatter 的产物" — absent frontmatter is out of scope
|
|
53
|
+
parsed += 1
|
|
54
|
+
end = t.find('\n---', 3)
|
|
55
|
+
try:
|
|
56
|
+
yaml.safe_load(t[3:end])
|
|
57
|
+
except Exception as ex:
|
|
58
|
+
print(f"✗ {os.path.relpath(f, d)}: {str(ex).splitlines()[0]}")
|
|
59
|
+
bad.append(f)
|
|
60
|
+
print(f"frontmatter-yaml: {parsed} frontmatter block(s) parsed · {len(bad)} failed")
|
|
61
|
+
sys.exit(1 if bad else 0)
|
|
62
|
+
PY
|
|
63
|
+
exit $?
|