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,243 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# pre-commit: block commits that would leave specs/implement_issues.md in a
|
|
4
|
+
# structurally invalid state.
|
|
5
|
+
#
|
|
6
|
+
# Why this exists (2026-09-12): the ledger is position-structured, and the
|
|
7
|
+
# failure mode is SILENT — a misfiled entry looks registered, the statistics
|
|
8
|
+
# table looks plausible, nothing errors. An instruction telling writers to
|
|
9
|
+
# self-check cannot reach a session that is already running (it does not
|
|
10
|
+
# re-read its command file), so the check has to be enforced at a point every
|
|
11
|
+
# writer must pass through. A commit is that point.
|
|
12
|
+
#
|
|
13
|
+
# Installed by: scripts/install-git-hooks.sh (git hooks are not versioned —
|
|
14
|
+
# the hook SOURCE lives here so a fresh clone installs the same check)
|
|
15
|
+
# Bypass (deliberate, e.g. committing a known-broken WIP state): git commit --no-verify
|
|
16
|
+
#
|
|
17
|
+
# Scope: exits immediately unless the LEDGER itself is staged, so it costs
|
|
18
|
+
# nothing on unrelated commits.
|
|
19
|
+
#
|
|
20
|
+
# ⚠️ **Two-repository layout** (adapted 2026-09-17 — the original assumed the
|
|
21
|
+
# ledger and `.specpro/` share a repository, which is false here): `specs/` is
|
|
22
|
+
# its own repository and is gitignored by the root one, so the staged path is
|
|
23
|
+
# `implement_issues.md` when the hook runs inside `specs/` and
|
|
24
|
+
# `specs/implement_issues.md` when it runs in a single-repository layout. Both
|
|
25
|
+
# are recognised, and the verifier is located relative to the repository root
|
|
26
|
+
# rather than assumed to be a sibling.
|
|
27
|
+
|
|
28
|
+
set -e
|
|
29
|
+
|
|
30
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
31
|
+
cd "$root" || exit 0
|
|
32
|
+
|
|
33
|
+
staged=$(git diff --cached --name-only)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Second check: the shape register and the shape-detection table must agree.
|
|
37
|
+
#
|
|
38
|
+
# `spec.md` -> FR-047 carries the register (judgement criteria — the single
|
|
39
|
+
# source); `plan.md` -> Constraint 7 carries the detection table (the same
|
|
40
|
+
# shape numbers, plus HOW each is detected — a plan-layer decision, FR-048).
|
|
41
|
+
# Two views of one fact, and nothing compared them: the register grew to 11 in
|
|
42
|
+
# v0.17 while the detection table sat at 10 until a human noticed.
|
|
43
|
+
#
|
|
44
|
+
# The assertion below is the one written into Constraint 7 itself; this is its
|
|
45
|
+
# enforcement point. Keep the two in step — if the assertion changes, change
|
|
46
|
+
# this with it.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
shape_sync_needed=0
|
|
49
|
+
for cand in specs/spec.md spec.md; do
|
|
50
|
+
echo "$staged" | grep -qx "$cand" && shape_sync_needed=1
|
|
51
|
+
done
|
|
52
|
+
for cand in specs/plan.md plan.md; do
|
|
53
|
+
echo "$staged" | grep -qx "$cand" && shape_sync_needed=1
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
if [ "$shape_sync_needed" = 1 ]; then
|
|
57
|
+
spec_f=""; plan_f=""
|
|
58
|
+
for cand in "$root/specs/spec.md" "$root/spec.md"; do
|
|
59
|
+
[ -f "$cand" ] && { spec_f="$cand"; break; }
|
|
60
|
+
done
|
|
61
|
+
for cand in "$root/specs/plan.md" "$root/plan.md"; do
|
|
62
|
+
[ -f "$cand" ] && { plan_f="$cand"; break; }
|
|
63
|
+
done
|
|
64
|
+
if [ -n "$spec_f" ] && [ -n "$plan_f" ]; then
|
|
65
|
+
drift=$(diff \
|
|
66
|
+
<(grep -oE '^ \| \*\*[0-9]+\*\*' "$spec_f" | grep -oE '[0-9]+' | sort -n) \
|
|
67
|
+
<(grep -oE '^\| \*\*[0-9]+\*\*' "$plan_f" | grep -oE '[0-9]+' | sort -n) || true)
|
|
68
|
+
if [ -n "$drift" ]; then
|
|
69
|
+
echo "✗ Commit blocked: the shape register and the shape-detection table disagree." >&2
|
|
70
|
+
echo " register : $spec_f (FR-047)" >&2
|
|
71
|
+
echo " detection : $plan_f (Constraint 7)" >&2
|
|
72
|
+
echo "$drift" | sed 's/^/ /' >&2
|
|
73
|
+
echo "" >&2
|
|
74
|
+
echo " Two views of one fact. Add the missing row to the detection table" >&2
|
|
75
|
+
echo " (with its 落点), or remove the one whose shape is gone. Bypass with" >&2
|
|
76
|
+
echo " git commit --no-verify if the drift is deliberate and in progress." >&2
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Third check: the deployment mirror must match its source.
|
|
84
|
+
#
|
|
85
|
+
# 宪法 1.1 requires that "命令与模板的改动与部署动作成对提交, 不留'已改源未部署'的
|
|
86
|
+
# 中间态"; 宪法 Q1 requires that "一份内容在多个位置存在时,它们不得在无人察觉的情况
|
|
87
|
+
# 下分叉". Both name the same act — deploy before committing — and this is where that
|
|
88
|
+
# act is enforced. Until this section existed, the only thing enforcing either was an
|
|
89
|
+
# instruction to run three `diff` commands by hand, which is a discipline, not a gate.
|
|
90
|
+
#
|
|
91
|
+
# ⚠️ Conditional, like the two checks around it: it runs only when a mirrored path is
|
|
92
|
+
# staged, so an unrelated commit pays nothing.
|
|
93
|
+
#
|
|
94
|
+
# ⚠️ Placed BEFORE the ledger check's early exit. That exit returns for every commit
|
|
95
|
+
# that does not touch the ledger — which is most of them — so a section below it would
|
|
96
|
+
# never run for exactly the commits this check is for.
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
deploy_check_needed=0
|
|
99
|
+
for cand in commands/ templates/ scripts/; do
|
|
100
|
+
echo "$staged" | grep -q "^${cand}" && deploy_check_needed=1
|
|
101
|
+
done
|
|
102
|
+
|
|
103
|
+
if [ "$deploy_check_needed" = 1 ]; then
|
|
104
|
+
deploy_verifier=""
|
|
105
|
+
for cand in scripts/bash/verify-deployment.sh .specpro/scripts/bash/verify-deployment.sh; do
|
|
106
|
+
[ -f "$cand" ] && { deploy_verifier="$cand"; break; }
|
|
107
|
+
done
|
|
108
|
+
if [ -z "$deploy_verifier" ]; then
|
|
109
|
+
echo "✗ Commit blocked: a mirrored path is staged, but verify-deployment.sh was not found." >&2
|
|
110
|
+
echo " Looked for: scripts/bash/verify-deployment.sh" >&2
|
|
111
|
+
echo " → a check that cannot run MUST NOT report as passing." >&2
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
if ! bash "$deploy_verifier"; then
|
|
115
|
+
cat >&2 <<'EOF'
|
|
116
|
+
|
|
117
|
+
────────────────────────────────────────────────────────────────────────
|
|
118
|
+
✗ Commit blocked: the deployment mirror is out of sync with its source.
|
|
119
|
+
|
|
120
|
+
宪法 1.1: 命令与模板的改动与部署动作成对提交,不留"已改源未部署"的中间态。
|
|
121
|
+
宪法 Q1 : 多副本不得在无人察觉的情况下分叉。
|
|
122
|
+
|
|
123
|
+
Fix: deploy the changed source first —
|
|
124
|
+
commands/ → .claude/skills/ (CLI install: tools/install/.venv/bin/python
|
|
125
|
+
-c "from specpro_cli.cli.entry import app; app()"
|
|
126
|
+
init . --agent claude --source .)
|
|
127
|
+
templates/ → .specpro/templates/
|
|
128
|
+
scripts/bash/→ .specpro/scripts/bash/
|
|
129
|
+
then re-stage and commit.
|
|
130
|
+
Bypass deliberately with: git commit --no-verify
|
|
131
|
+
────────────────────────────────────────────────────────────────────────
|
|
132
|
+
EOF
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Fourth check: a cross-round record must not sit on a wholesale-rebuild path.
|
|
139
|
+
#
|
|
140
|
+
# plan.md → Constraint 11 (`FR-025`) requires it, and its `**强制点**:` line names
|
|
141
|
+
# `scripts/bash/verify-cumulative-records.sh`. ⚠️ Until this section existed that
|
|
142
|
+
# line named a script **nothing ran** — which is the very shape the section below
|
|
143
|
+
# exists to stop: a constraint whose enforcement point is a *statement* rather
|
|
144
|
+
# than a *gate*. The failure it guards is silent by construction — the rebuild is
|
|
145
|
+
# a successful `cp`, so every exit code in the chain is 0 while the record's
|
|
146
|
+
# contents are gone (measured 2026-09-19: a baseline section went 10 rows → 0).
|
|
147
|
+
#
|
|
148
|
+
# ⚠️ Conditional, like the checks around it: it runs only when a path that can
|
|
149
|
+
# carry EITHER side is staged — `commands/` (where the rebuild sites live) or
|
|
150
|
+
# `specs/` (where cross-round records live). An unrelated commit pays nothing.
|
|
151
|
+
#
|
|
152
|
+
# ⚠️ Placed BEFORE the ledger check's early exit below, for the reason stated
|
|
153
|
+
# there: a section placed after it would never run for most commits.
|
|
154
|
+
#
|
|
155
|
+
# ⚠️ The verifier declares both sides itself (a record says `**Cumulative**:` in
|
|
156
|
+
# its own header; a rebuild is a `cp` to a shell variable in `commands/`), so this
|
|
157
|
+
# section adds no registry — it only supplies the thing that was missing.
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
records_check_needed=0
|
|
160
|
+
for cand in commands/ specs/; do
|
|
161
|
+
echo "$staged" | grep -q "^${cand}" && records_check_needed=1
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
if [ "$records_check_needed" = 1 ]; then
|
|
165
|
+
records_verifier=""
|
|
166
|
+
for cand in scripts/bash/verify-cumulative-records.sh .specpro/scripts/bash/verify-cumulative-records.sh; do
|
|
167
|
+
[ -f "$cand" ] && { records_verifier="$cand"; break; }
|
|
168
|
+
done
|
|
169
|
+
if [ -z "$records_verifier" ]; then
|
|
170
|
+
echo "✗ Commit blocked: commands/ or specs/ is staged, but verify-cumulative-records.sh was not found." >&2
|
|
171
|
+
echo " Looked for: scripts/bash/verify-cumulative-records.sh" >&2
|
|
172
|
+
echo " → a check that cannot run MUST NOT report as passing." >&2
|
|
173
|
+
exit 1
|
|
174
|
+
fi
|
|
175
|
+
if ! bash "$records_verifier"; then
|
|
176
|
+
cat >&2 <<'EOF'
|
|
177
|
+
|
|
178
|
+
────────────────────────────────────────────────────────────────────────
|
|
179
|
+
✗ Commit blocked: a cross-round record sits on a wholesale-rebuild path.
|
|
180
|
+
|
|
181
|
+
plan.md → Constraint 11: 一份跨轮累积的记录 MUST 住在没有任何命令每轮重建的
|
|
182
|
+
产物里 —— 容器每轮重建 ⇒ 累积的内容静默消失,而没有任何一步会报错。
|
|
183
|
+
|
|
184
|
+
Fix: move the record out of the rebuilt artifact (the verifier's hit line
|
|
185
|
+
names both the record and the command that rebuilds it), then
|
|
186
|
+
re-stage and commit.
|
|
187
|
+
|
|
188
|
+
Why this is a gate and not a note: a record's value IS its accumulation,
|
|
189
|
+
and a rebuild destroys it with exit code 0 — nothing else would say so.
|
|
190
|
+
Bypass deliberately with: git commit --no-verify
|
|
191
|
+
────────────────────────────────────────────────────────────────────────
|
|
192
|
+
EOF
|
|
193
|
+
exit 1
|
|
194
|
+
fi
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# First check: the ledger's own structural invariants.
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
ledger_rel=""
|
|
201
|
+
case "$staged" in
|
|
202
|
+
*implement_issues.md*)
|
|
203
|
+
for cand in specs/implement_issues.md implement_issues.md; do
|
|
204
|
+
if echo "$staged" | grep -qx "$cand"; then ledger_rel="$cand"; break; fi
|
|
205
|
+
done
|
|
206
|
+
;;
|
|
207
|
+
esac
|
|
208
|
+
[ -z "$ledger_rel" ] && exit 0
|
|
209
|
+
|
|
210
|
+
# Locate the verifier. It lives beside `.specpro/` — in the specs repository
|
|
211
|
+
# that is the PARENT directory, not a sibling.
|
|
212
|
+
verifier=""
|
|
213
|
+
for cand in .specpro/scripts/bash/verify-ledger.sh ../.specpro/scripts/bash/verify-ledger.sh; do
|
|
214
|
+
if [ -x "$cand" ]; then verifier="$cand"; break; fi
|
|
215
|
+
done
|
|
216
|
+
if [ -z "$verifier" ]; then
|
|
217
|
+
echo "pre-commit: verify-ledger.sh not found — skipping ledger check" >&2
|
|
218
|
+
exit 0
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
if ! LEDGER="$root/$ledger_rel" "$verifier"; then
|
|
222
|
+
cat >&2 <<'EOF'
|
|
223
|
+
|
|
224
|
+
────────────────────────────────────────────────────────────────────────
|
|
225
|
+
✗ Commit blocked: specs/implement_issues.md failed its structural checks.
|
|
226
|
+
|
|
227
|
+
The violations are listed above. In short, the ledger's invariants are:
|
|
228
|
+
· every issue entry sits INSIDE one of its '## [...] Phase Issues'
|
|
229
|
+
sections — never appended to the end of the file
|
|
230
|
+
· every entry's block carries exactly ONE `**Related**:` line, and every
|
|
231
|
+
field line lies inside some entry's block
|
|
232
|
+
· the statistics table at the top matches the actual per-section counts
|
|
233
|
+
· the file ends with exactly one newline
|
|
234
|
+
|
|
235
|
+
The section entries are authoritative; the statistics table is derived.
|
|
236
|
+
Fix the file and re-stage it, or bypass deliberately with:
|
|
237
|
+
git commit --no-verify
|
|
238
|
+
────────────────────────────────────────────────────────────────────────
|
|
239
|
+
EOF
|
|
240
|
+
exit 1
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
exit 0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Install this repository's git hooks. **Git hooks are not versioned**, so a
|
|
4
|
+
# fresh clone has none — the sources live under `scripts/git-hooks/` and this
|
|
5
|
+
# script is what makes them real. Run it once per clone.
|
|
6
|
+
#
|
|
7
|
+
# Why a script rather than a README line: seven command documents state that a
|
|
8
|
+
# pre-commit hook enforces `verify-ledger.sh` ("a violation blocks the commit
|
|
9
|
+
# rather than being discovered later"). That claim was **false** — no hook
|
|
10
|
+
# existed and nothing installed one — so every reader who skipped the manual
|
|
11
|
+
# check did so with a clear conscience, which is exactly the failure the
|
|
12
|
+
# sentence promised to prevent. A check that is *assumed* to exist and does not
|
|
13
|
+
# is worse than no check at all.
|
|
14
|
+
#
|
|
15
|
+
# Covers BOTH repositories: the root one (tool source) and `specs/` (its own
|
|
16
|
+
# repository — the ledger lives there, so the ledger hook matters most there).
|
|
17
|
+
#
|
|
18
|
+
# Usage: scripts/install-git-hooks.sh [--check]
|
|
19
|
+
# --check report installation state without changing anything
|
|
20
|
+
# Exit: 0 = all hooks installed (or, with --check, all already installed)
|
|
21
|
+
|
|
22
|
+
set -e
|
|
23
|
+
|
|
24
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
25
|
+
echo "✗ not inside a git repository" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
}
|
|
28
|
+
cd "$root"
|
|
29
|
+
|
|
30
|
+
CHECK=0
|
|
31
|
+
[ "${1:-}" = "--check" ] && CHECK=1
|
|
32
|
+
|
|
33
|
+
src="$root/scripts/git-hooks/pre-commit"
|
|
34
|
+
[ -f "$src" ] || { echo "✗ hook source missing: $src" >&2; exit 1; }
|
|
35
|
+
|
|
36
|
+
# Repositories that should carry the hook. `specs/` is its own repository and
|
|
37
|
+
# is gitignored by the root one — both are listed explicitly because "install
|
|
38
|
+
# where the ledger is" and "install where the tool is" are different questions.
|
|
39
|
+
repos="$root"
|
|
40
|
+
[ -d "$root/specs/.git" ] && repos="$repos $root/specs"
|
|
41
|
+
|
|
42
|
+
missing=0
|
|
43
|
+
for repo in $repos; do
|
|
44
|
+
dst="$repo/.git/hooks/pre-commit"
|
|
45
|
+
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
46
|
+
echo " ✓ up to date: $dst"
|
|
47
|
+
continue
|
|
48
|
+
fi
|
|
49
|
+
if [ "$CHECK" = 1 ]; then
|
|
50
|
+
echo " ✗ not installed (or stale): $dst"
|
|
51
|
+
missing=$((missing + 1))
|
|
52
|
+
continue
|
|
53
|
+
fi
|
|
54
|
+
mkdir -p "$repo/.git/hooks"
|
|
55
|
+
cp "$src" "$dst"
|
|
56
|
+
chmod +x "$dst"
|
|
57
|
+
echo " ✓ installed: $dst"
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
if [ "$CHECK" = 1 ] && [ "$missing" -gt 0 ]; then
|
|
61
|
+
echo ""
|
|
62
|
+
echo "✗ $missing hook(s) missing — run scripts/install-git-hooks.sh" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
echo ""
|
|
67
|
+
echo "✓ git hooks ready. Bypass a deliberate commit with: git commit --no-verify"
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
#
|
|
3
|
+
# Anti-coupling scan — PowerShell twin of scripts/bash/check-anti-coupling.sh.
|
|
4
|
+
#
|
|
5
|
+
# Same stdout, same stderr, same exit code (FR-008). Pass O, made runnable.
|
|
6
|
+
#
|
|
7
|
+
# ⚠️ **The patterns are NOT in this file, and that is the design.** This script is a
|
|
8
|
+
# distributed asset: a hardcoded host term here would BE the leak it exists to find
|
|
9
|
+
# (O3), and the same for a pinned framework name (O2). Patterns live in a project-side
|
|
10
|
+
# file (`specs/anti-coupling-patterns.txt`), which ships to nobody. Class 1 needs no
|
|
11
|
+
# list at all — foreign numeric identifiers are DERIVED, since "foreign" means exactly
|
|
12
|
+
# "not allocated by this project".
|
|
13
|
+
|
|
14
|
+
# Repository root — resolved POSITION-INDEPENDENTLY, then TESTED (T180 / ISS-124).
|
|
15
|
+
#
|
|
16
|
+
# ⚠️ The previous form was `$PSScriptRoot/../..`, which is correct only from the SOURCE
|
|
17
|
+
# layout. From the deployed mirror (`.specpro/scripts/bash/…`) it lands one level short,
|
|
18
|
+
# on `.specpro` — and every path below then names a tree that does not exist. The
|
|
19
|
+
# failure is silent in the worst direction: a scan over a tree it never read still
|
|
20
|
+
# prints `✓ none`, indistinguishable from a clean one.
|
|
21
|
+
#
|
|
22
|
+
# ⚠️ The sentinel tests the RESULT, not the method — including for an override.
|
|
23
|
+
function Test-RepoRoot([string]$p) {
|
|
24
|
+
(Test-Path (Join-Path $p 'commands')) -and
|
|
25
|
+
(Test-Path (Join-Path $p 'templates')) -and
|
|
26
|
+
(Test-Path (Join-Path $p 'scripts'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# ⚠️ Two sentinels, and which one applies depends on WHO named the base (T205 / ISS-165).
|
|
30
|
+
# Not a relaxation of the guard: it is the guard asking the right question in each case.
|
|
31
|
+
# * auto-resolution answers "is this a repository root?" — the three-directory shape is
|
|
32
|
+
# the evidence, and it must hold because the script CHOSE the directory.
|
|
33
|
+
# * an explicit override answers "can I read what I am about to scan?" — the caller has
|
|
34
|
+
# already named the tree, and what this scanner reads is `commands/` and `specs/`.
|
|
35
|
+
# Demanding `templates/` and `scripts/` here asks about directories it never opens.
|
|
36
|
+
# Measured before the split: the fixtures build exactly `commands/` and `specs/`, so all of
|
|
37
|
+
# them were refused before the scan — three unit tests red while the real repository (which
|
|
38
|
+
# has all three) stayed green. A guard whose subjects can no longer be exercised is not a
|
|
39
|
+
# stronger guard.
|
|
40
|
+
function Test-ScannableBase([string]$p) {
|
|
41
|
+
$p -and (Test-Path (Join-Path $p 'commands')) -and (Test-Path (Join-Path $p 'specs'))
|
|
42
|
+
}
|
|
43
|
+
function Resolve-Base {
|
|
44
|
+
$t = git rev-parse --show-toplevel 2>$null
|
|
45
|
+
if ($t -and (Test-RepoRoot $t)) { return $t }
|
|
46
|
+
foreach ($cand in @((Join-Path $PSScriptRoot '..' '..'), (Join-Path $PSScriptRoot '..' '..' '..'))) {
|
|
47
|
+
if (Test-Path $cand) {
|
|
48
|
+
$p = (Resolve-Path $cand).Path
|
|
49
|
+
if (Test-RepoRoot $p) { return $p }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return $null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$Base = if ($env:ANTI_COUPLING_BASE) { $env:ANTI_COUPLING_BASE } else { Resolve-Base }
|
|
56
|
+
|
|
57
|
+
# The sentinel is chosen by the base's SOURCE, and the message names the one that ran — a
|
|
58
|
+
# reader told to expect `templates/` when the failing check never looks at it would go add a
|
|
59
|
+
# directory that changes nothing.
|
|
60
|
+
$sentinelOk = if ($env:ANTI_COUPLING_BASE) { Test-ScannableBase $Base } else { Test-RepoRoot $Base }
|
|
61
|
+
$expect = if ($env:ANTI_COUPLING_BASE) { 'commands/ and specs/ (what this scanner reads)' }
|
|
62
|
+
else { 'commands/ , templates/ and scripts/ (a repository root)' }
|
|
63
|
+
if (-not $sentinelOk) {
|
|
64
|
+
$shown = if ($Base) { $Base } else { '<unresolved>' }
|
|
65
|
+
[Console]::Error.WriteLine("✗ BASE is not usable: '$shown'")
|
|
66
|
+
[Console]::Error.WriteLine(" Expected it to contain $expect .")
|
|
67
|
+
if ($env:ANTI_COUPLING_BASE) {
|
|
68
|
+
[Console]::Error.WriteLine(" Source: the ANTI_COUPLING_BASE override.")
|
|
69
|
+
} else {
|
|
70
|
+
[Console]::Error.WriteLine(" Source: auto-resolution (git toplevel, then the script's two possible depths).")
|
|
71
|
+
}
|
|
72
|
+
[Console]::Error.WriteLine(" ⚠️ Every finding below would be derived from a tree this script never read —")
|
|
73
|
+
[Console]::Error.WriteLine(" an absent subject and a clean subject are the same output otherwise.")
|
|
74
|
+
exit 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
$Assets = if ($env:ANTI_COUPLING_ASSETS) { $env:ANTI_COUPLING_ASSETS -split '\s+' }
|
|
78
|
+
else { @('commands', 'templates', 'scripts') }
|
|
79
|
+
$Patterns = if ($env:ANTI_COUPLING_PATTERNS) { $env:ANTI_COUPLING_PATTERNS }
|
|
80
|
+
else { Join-Path $Base 'specs/anti-coupling-patterns.txt' }
|
|
81
|
+
$OwnSpec = if ($env:ANTI_COUPLING_OWN_SPEC) { $env:ANTI_COUPLING_OWN_SPEC }
|
|
82
|
+
else { Join-Path $Base 'specs/spec.md' }
|
|
83
|
+
$OwnTasks = if ($env:ANTI_COUPLING_OWN_TASKS) { $env:ANTI_COUPLING_OWN_TASKS }
|
|
84
|
+
else { Join-Path $Base 'specs/tasks.md' }
|
|
85
|
+
$OwnLedger = if ($env:ANTI_COUPLING_OWN_LEDGER) { $env:ANTI_COUPLING_OWN_LEDGER }
|
|
86
|
+
else { Join-Path $Base 'specs/implement_issues.md' }
|
|
87
|
+
|
|
88
|
+
$Fail = 0
|
|
89
|
+
Write-Output "Anti-coupling scan: $Base"
|
|
90
|
+
Write-Output ""
|
|
91
|
+
|
|
92
|
+
$AssetPaths = @($Assets | ForEach-Object { Join-Path $Base $_ } | Where-Object { Test-Path -LiteralPath $_ -PathType Container })
|
|
93
|
+
|
|
94
|
+
# --- class 1: foreign numeric identifiers (DERIVED, not listed) ----------------------
|
|
95
|
+
# ⚠️ The criterion is O1's own wording: "A number above this document's own range is the
|
|
96
|
+
# mechanical tell" — a RANGE test, not a set difference.
|
|
97
|
+
#
|
|
98
|
+
# ⚠️ **Each prefix's range comes from the artifact that ALLOCATES it**, never from every
|
|
99
|
+
# artifact that mentions it. `tasks.md` quotes the foreign identifiers it must fix, so
|
|
100
|
+
# reading the FR range from it made the maximum come out at 145 instead of 57 — and a
|
|
101
|
+
# range that has absorbed the very identifiers it should catch leaves the class blind.
|
|
102
|
+
# The measurer must not be fed the thing being measured.
|
|
103
|
+
Write-Output " [1/3] foreign numeric identifiers (range-derived from $OwnSpec + tasks)"
|
|
104
|
+
|
|
105
|
+
function Get-OwnSource([string]$Prefix) {
|
|
106
|
+
switch ($Prefix) {
|
|
107
|
+
'FR' { return $OwnSpec }
|
|
108
|
+
'US' { return $OwnSpec }
|
|
109
|
+
'T' { return $OwnTasks }
|
|
110
|
+
'ISS' { return $OwnLedger }
|
|
111
|
+
'D' { return $OwnSpec }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
$DerivedAny = 0
|
|
116
|
+
$Foreign = @()
|
|
117
|
+
foreach ($prefix in @('FR', 'US', 'T', 'ISS', 'D')) {
|
|
118
|
+
$src = Get-OwnSource $prefix
|
|
119
|
+
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { continue }
|
|
120
|
+
$nums = @([regex]::Matches((Get-Content -LiteralPath $src -Raw), "\b$prefix-([0-9]+)") |
|
|
121
|
+
ForEach-Object { [int]$_.Groups[1].Value })
|
|
122
|
+
if ($nums.Count -eq 0) { continue }
|
|
123
|
+
# ⚠️ `Measure-Object` yields a DOUBLE even for integer input, and a double is not
|
|
124
|
+
# valid for the `{2:D3}` integer format specifier below ("Format specifier was invalid").
|
|
125
|
+
$max = [int](($nums | Measure-Object -Maximum).Maximum)
|
|
126
|
+
$DerivedAny = 1
|
|
127
|
+
|
|
128
|
+
# ⚠️ **The file name is part of the finding** (ISS-116 / T165). A report line that names
|
|
129
|
+
# the identifier but not where it lives is half-delivered — classes 2 and 3 both print
|
|
130
|
+
# the file, and this one did not.
|
|
131
|
+
#
|
|
132
|
+
# ⚠️ **The deduplication is on the COMPOSED string**, not on the identifier: the same
|
|
133
|
+
# foreign number legitimately appears in two files, and uniqueness over the id alone
|
|
134
|
+
# collapses them into one, hiding the second location.
|
|
135
|
+
foreach ($p in $AssetPaths) {
|
|
136
|
+
foreach ($f in Get-ChildItem -LiteralPath $p -Recurse -File) {
|
|
137
|
+
$text = Get-Content -LiteralPath $f.FullName -Raw
|
|
138
|
+
# Relative to the base, with forward slashes — the Bash side prints
|
|
139
|
+
# `${file#$BASE/}`, and a backslash would be a stdout difference, not a style one.
|
|
140
|
+
$rel = $f.FullName.Substring($Base.Length).TrimStart('/', '\').Replace('\', '/')
|
|
141
|
+
foreach ($m in [regex]::Matches($text, "\b$prefix-([0-9]+)")) {
|
|
142
|
+
$n = [int]$m.Groups[1].Value
|
|
143
|
+
if ($n -gt $max) {
|
|
144
|
+
$Foreign += ("{0} ({1} max={2:D3}) {3}" -f $m.Value, $prefix, $max, $rel)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
# ⚠️ Ordinal, not culture-aware: the Bash side sorts with `LC_ALL=C`, and a culture-sensitive
|
|
151
|
+
# comparison orders punctuation and case differently — a correct set in a different order is
|
|
152
|
+
# still a different stdout, and the parity contract is byte equality (FR-008).
|
|
153
|
+
$Foreign = @($Foreign | Sort-Object -Unique -Culture '')
|
|
154
|
+
|
|
155
|
+
if ($DerivedAny -eq 0) {
|
|
156
|
+
Write-Output " ✗ could not derive this project's own identifier ranges from $OwnSpec / $OwnTasks"
|
|
157
|
+
Write-Output " → with no range to compare against, EVERY identifier reads as foreign (noise)"
|
|
158
|
+
Write-Output " or none does (a silent pass). A scan that cannot tell its own numbers from"
|
|
159
|
+
Write-Output " a foreign one MUST NOT report clean."
|
|
160
|
+
$Fail = 1
|
|
161
|
+
} elseif ($Foreign.Count -gt 0) {
|
|
162
|
+
Write-Output " ✗ identifiers above this project's own range, in shipped assets:"
|
|
163
|
+
foreach ($x in $Foreign) { Write-Output " $x" }
|
|
164
|
+
Write-Output " → a reader outside this repository cannot resolve them (O1, HIGH)."
|
|
165
|
+
$Fail = 1
|
|
166
|
+
} else {
|
|
167
|
+
Write-Output " ✓ none"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# --- classes 2 and 3: declared patterns ---------------------------------------------
|
|
171
|
+
function Test-Class([string]$tag, [string]$label, [string]$severity) {
|
|
172
|
+
if (-not (Test-Path -LiteralPath $Patterns -PathType Leaf)) {
|
|
173
|
+
Write-Output " ✗ patterns file not found: $Patterns"
|
|
174
|
+
$script:Fail = 1
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
$declared = @(Get-Content -LiteralPath $Patterns | Where-Object { $_ -match "^$tag\|" })
|
|
178
|
+
if ($declared.Count -eq 0) {
|
|
179
|
+
Write-Output " ✗ no patterns declared for this class in $Patterns"
|
|
180
|
+
Write-Output " → Pass O requires all three classes; an uncovered class is the finding,"
|
|
181
|
+
Write-Output " not a gap to note and move past."
|
|
182
|
+
$script:Fail = 1
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
# ⚠️ **A hit is a FINDING unless the pattern file lists it as reviewed** (T200).
|
|
186
|
+
# The patterns are a coarse word filter; a match cannot tell a worked example from a
|
|
187
|
+
# parallel list of alternatives, and `T177` triaged this class's hits into both. The
|
|
188
|
+
# triage is recorded in the pattern file as `reviewed::<class>::<regex>::<file>::<why>`,
|
|
189
|
+
# so the classification is an INPUT the reviewer can see and change — never a silent
|
|
190
|
+
# relaxation. Removing an entry restores its finding.
|
|
191
|
+
$hits = @()
|
|
192
|
+
$reviewedHits = @()
|
|
193
|
+
foreach ($line in $declared) {
|
|
194
|
+
$parts = $line -split '\|'
|
|
195
|
+
$regex = $parts[1]; $why = $parts[2]
|
|
196
|
+
foreach ($p in $AssetPaths) {
|
|
197
|
+
foreach ($f in Get-ChildItem -LiteralPath $p -Recurse -File) {
|
|
198
|
+
$text = Get-Content -LiteralPath $f.FullName -Raw
|
|
199
|
+
if ([regex]::IsMatch($text, $regex)) {
|
|
200
|
+
$rel = $f.FullName.Substring($Base.Length).TrimStart('\', '/')
|
|
201
|
+
$marker = "reviewed::${tag}::${regex}::${rel}::"
|
|
202
|
+
if (Select-String -LiteralPath $Patterns -SimpleMatch -Pattern $marker -Quiet) {
|
|
203
|
+
$reviewedHits += " ($why) $rel"
|
|
204
|
+
} else {
|
|
205
|
+
$hits += " ($why) $rel"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if ($reviewedHits.Count -gt 0) {
|
|
212
|
+
Write-Output " ⊘ reviewed generic reference(s) — NOT findings, each with its reason in"
|
|
213
|
+
Write-Output " $Patterns (remove an entry to restore it as a finding):"
|
|
214
|
+
[System.Array]::Sort($reviewedHits, [System.StringComparer]::Ordinal)
|
|
215
|
+
foreach ($h in $reviewedHits) { Write-Output " $h" }
|
|
216
|
+
}
|
|
217
|
+
if ($hits.Count -gt 0) {
|
|
218
|
+
Write-Output " ✗ $label present in shipped assets ($severity):"
|
|
219
|
+
# ⚠️ ORDINAL sort, not Sort-Object's default culture-aware comparison: the bash
|
|
220
|
+
# twin sorts with `sort`, and two correct orderings that disagree are a parity
|
|
221
|
+
# failure. Same set, different order, different stdout.
|
|
222
|
+
[System.Array]::Sort($hits, [System.StringComparer]::Ordinal)
|
|
223
|
+
foreach ($h in $hits) { Write-Output " $h" }
|
|
224
|
+
$script:Fail = 1
|
|
225
|
+
} else {
|
|
226
|
+
# ⚠️ Reported even when the ⊘ block above is present: "no findings" and "nothing was
|
|
227
|
+
# scanned" must not look alike, and the reviewed set is disclosed rather than hidden.
|
|
228
|
+
Write-Output " ✓ none"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
Write-Output " [2/3] host technology stack (patterns from $Patterns)"
|
|
233
|
+
Test-Class 'tech' 'host technology stack' 'O2, HIGH'
|
|
234
|
+
|
|
235
|
+
Write-Output " [3/3] host business terms and module names (patterns from $Patterns)"
|
|
236
|
+
Test-Class 'term' 'host business term / module name' 'O3, HIGH'
|
|
237
|
+
|
|
238
|
+
Write-Output ""
|
|
239
|
+
if ($Fail -eq 0) {
|
|
240
|
+
Write-Output "✓ anti-coupling: no findings across all three classes"
|
|
241
|
+
exit 0
|
|
242
|
+
} else {
|
|
243
|
+
Write-Output "✗ anti-coupling: FINDINGS (see above)"
|
|
244
|
+
Write-Output " → Pass O reports only; the fix is a tool-source change. Register it in the"
|
|
245
|
+
Write-Output " [tasks] section of specs/implement_issues.md — a finding without a route is"
|
|
246
|
+
Write-Output " indistinguishable from one never reported. And the fix has TWO halves: remove"
|
|
247
|
+
Write-Output " the trace, AND add the pattern that would have caught it."
|
|
248
|
+
exit 1
|
|
249
|
+
}
|