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.
Files changed (76) hide show
  1. specpro_cli/__init__.py +16 -0
  2. specpro_cli/assets/commands/specpro.analyze.md +1102 -0
  3. specpro_cli/assets/commands/specpro.checklist.md +335 -0
  4. specpro_cli/assets/commands/specpro.clarify.md +581 -0
  5. specpro_cli/assets/commands/specpro.constitution.md +488 -0
  6. specpro_cli/assets/commands/specpro.feature.md +115 -0
  7. specpro_cli/assets/commands/specpro.implement.md +1881 -0
  8. specpro_cli/assets/commands/specpro.manual-test.md +206 -0
  9. specpro_cli/assets/commands/specpro.plan.md +3284 -0
  10. specpro_cli/assets/commands/specpro.qc.md +1489 -0
  11. specpro_cli/assets/commands/specpro.scenarios.md +154 -0
  12. specpro_cli/assets/commands/specpro.specify.md +1449 -0
  13. specpro_cli/assets/commands/specpro.status.md +863 -0
  14. specpro_cli/assets/commands/specpro.tasks.md +1207 -0
  15. specpro_cli/assets/commands/specpro.test-implement.md +462 -0
  16. specpro_cli/assets/commands/specpro.test-plan.md +383 -0
  17. specpro_cli/assets/commands/specpro.user-manual.md +178 -0
  18. specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
  19. specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
  20. specpro_cli/assets/scripts/bash/common.sh +88 -0
  21. specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
  22. specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
  23. specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
  24. specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
  25. specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
  26. specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
  27. specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
  28. specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
  29. specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
  30. specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
  31. specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
  32. specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
  33. specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
  34. specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
  35. specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
  36. specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
  37. specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
  38. specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
  39. specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
  40. specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
  41. specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
  42. specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
  43. specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
  44. specpro_cli/assets/templates/agent-context-template.md +49 -0
  45. specpro_cli/assets/templates/assumptions-template.md +248 -0
  46. specpro_cli/assets/templates/checklist-template.md +40 -0
  47. specpro_cli/assets/templates/clarifications-template.md +155 -0
  48. specpro_cli/assets/templates/constitution-template.md +50 -0
  49. specpro_cli/assets/templates/feature-spec-template.md +66 -0
  50. specpro_cli/assets/templates/plan-overview-template.md +150 -0
  51. specpro_cli/assets/templates/plan-template.md +387 -0
  52. specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
  53. specpro_cli/assets/templates/requirements-template.md +356 -0
  54. specpro_cli/assets/templates/spec-template.md +267 -0
  55. specpro_cli/assets/templates/tasks-template.md +252 -0
  56. specpro_cli/assets/templates/test-tasks-template.md +174 -0
  57. specpro_cli/cli/__init__.py +5 -0
  58. specpro_cli/cli/cmd_init.py +416 -0
  59. specpro_cli/cli/cmd_remove.py +122 -0
  60. specpro_cli/cli/entry.py +181 -0
  61. specpro_cli/integrations/__init__.py +36 -0
  62. specpro_cli/integrations/base.py +601 -0
  63. specpro_cli/integrations/claude/__init__.py +101 -0
  64. specpro_cli/integrations/copilot/__init__.py +153 -0
  65. specpro_cli/integrations/cursor_agent/__init__.py +51 -0
  66. specpro_cli/integrations/gemini/__init__.py +44 -0
  67. specpro_cli/integrations/opencode/__init__.py +48 -0
  68. specpro_cli/integrations/qodercli/__init__.py +54 -0
  69. specpro_cli/integrations/registry.py +88 -0
  70. specpro_cli/packaged/__init__.py +5 -0
  71. specpro_cli/packaged/sync.py +106 -0
  72. specpro_cli-0.1.0.dist-info/METADATA +117 -0
  73. specpro_cli-0.1.0.dist-info/RECORD +76 -0
  74. specpro_cli-0.1.0.dist-info/WHEEL +4 -0
  75. specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
  76. 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
+ }