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,383 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
<#
|
|
3
|
+
.SYNOPSIS
|
|
4
|
+
Verify the implementation-issues ledger's structural invariants.
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Contract-equivalent to `scripts/bash/verify-ledger.sh`. The contract is deliberately
|
|
7
|
+
strong here: **for the same ledger, both implementations print byte-identical output
|
|
8
|
+
and return the same exit code** — this script is invoked from command documents and
|
|
9
|
+
from a pre-commit hook, and a check that disagrees with its twin across platforms is
|
|
10
|
+
worse than one that is merely missing, because each side looks authoritative.
|
|
11
|
+
|
|
12
|
+
Invariants, in the order the Bash side checks them:
|
|
13
|
+
1. the five sections exist and are in canonical order
|
|
14
|
+
2. no entry sits outside every section (appended past the sentinel)
|
|
15
|
+
3. each section's statistics row matches its actual entry counts
|
|
16
|
+
5. every ID named in a Pending-Items column exists as a pending entry
|
|
17
|
+
4. the file ends with exactly one newline
|
|
18
|
+
(The numbering matches the Bash source; 4 and 5 are ordered by their position there.)
|
|
19
|
+
|
|
20
|
+
.OUTPUTS
|
|
21
|
+
Exit 0 = all invariants hold; 1 = at least one violation, details printed.
|
|
22
|
+
#>
|
|
23
|
+
Set-StrictMode -Version Latest
|
|
24
|
+
$ErrorActionPreference = 'Stop'
|
|
25
|
+
|
|
26
|
+
$LEDGER = if ($env:LEDGER) { $env:LEDGER } else { 'specs/implement_issues.md' }
|
|
27
|
+
$script:FAIL = 0
|
|
28
|
+
|
|
29
|
+
if (-not (Test-Path -LiteralPath $LEDGER -PathType Leaf)) {
|
|
30
|
+
Write-Error "✗ $LEDGER not found (run from the repository root)"
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# --- 0. the section list is DERIVED, not transcribed ---------------------------------
|
|
35
|
+
# ⚠️ This block was `@('specify','plan','tasks','test-plan')` — a hand-copied duplicate
|
|
36
|
+
# of the contract's table. The bash side removed that copy in ISS-88, and the two then
|
|
37
|
+
# diverged exactly the way two sources of one fact do: the contract gained
|
|
38
|
+
# `[constitution]`, this side never learned about it, and **nothing compared them**.
|
|
39
|
+
# A copy is not a check; deriving one from the other is.
|
|
40
|
+
$CONTRACT = if ($env:CONTRACT) { $env:CONTRACT }
|
|
41
|
+
else { Join-Path (Split-Path -Parent $LEDGER) 'contracts/ledger.md' }
|
|
42
|
+
|
|
43
|
+
$SECTIONS = @()
|
|
44
|
+
if (Test-Path -LiteralPath $CONTRACT -PathType Leaf) {
|
|
45
|
+
$inTable = $false
|
|
46
|
+
foreach ($line in Get-Content -LiteralPath $CONTRACT) {
|
|
47
|
+
if ($line -match '^## Sections and their consumers') { $inTable = $true; continue }
|
|
48
|
+
if ($inTable -and $line -match '^## ') { break }
|
|
49
|
+
if ($inTable -and $line -match '^\| `\[([a-z-]+)\]`') { $SECTIONS += $Matches[1] }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ($SECTIONS.Count -eq 0) {
|
|
54
|
+
# ⚠️ An empty list is NOT "nothing to check": every section-level check below would
|
|
55
|
+
# pass vacuously, and that is indistinguishable from a clean ledger.
|
|
56
|
+
[Console]::Error.WriteLine("✗ could not derive the section list from $CONTRACT")
|
|
57
|
+
[Console]::Error.WriteLine(" A verifier with no sections to check reports success without checking anything.")
|
|
58
|
+
[Console]::Error.WriteLine(" Fix: run from the repository root, or set CONTRACT=<path to the ledger contract>.")
|
|
59
|
+
exit 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# --- 0b. and the comparison runs BOTH ways -------------------------------------------
|
|
63
|
+
# Deriving the list closes "the contract names a section the ledger lacks". It does NOT
|
|
64
|
+
# close the reverse: a section present in the ledger but absent from the contract is
|
|
65
|
+
# simply never iterated, so it is never checked — and the run still reports clean.
|
|
66
|
+
$inLedger = @(Get-Content -LiteralPath $LEDGER |
|
|
67
|
+
Where-Object { $_ -match '^## \[[a-z-]+\] Phase Issues$' } |
|
|
68
|
+
ForEach-Object { [regex]::Match($_, '^## \[([a-z-]+)\]').Groups[1].Value } |
|
|
69
|
+
Sort-Object -Unique)
|
|
70
|
+
$undeclared = @($inLedger | Where-Object { $SECTIONS -notcontains $_ })
|
|
71
|
+
if ($undeclared.Count -gt 0) {
|
|
72
|
+
[Console]::Error.WriteLine("✗ section(s) present in the ledger but absent from the contract's table:")
|
|
73
|
+
foreach ($u in $undeclared) { [Console]::Error.WriteLine(" $u") }
|
|
74
|
+
[Console]::Error.WriteLine(" → nothing checks them: the verifier iterates the contract's list.")
|
|
75
|
+
[Console]::Error.WriteLine(" Add the row to $CONTRACT (with its consumer), or remove the section.")
|
|
76
|
+
exit 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# The whole ledger as an array of lines. Every check below reads this rather than
|
|
80
|
+
# re-reading the file, so a mid-run edit cannot make two checks disagree.
|
|
81
|
+
$lines = Get-Content -LiteralPath $LEDGER
|
|
82
|
+
|
|
83
|
+
function Get-SectionHeadingLine {
|
|
84
|
+
param([string]$Section)
|
|
85
|
+
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
86
|
+
if ($lines[$i] -ceq "## [$Section] Phase Issues") { return $i } # 0-based
|
|
87
|
+
}
|
|
88
|
+
return -1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Get-SectionBody {
|
|
92
|
+
param([int]$HeadingIndex)
|
|
93
|
+
# Body runs to the next '## [' heading, the sentinel, or EOF — whichever is first.
|
|
94
|
+
# Stopping at the sentinel matters: content appended past it is orphaned and must
|
|
95
|
+
# NOT be counted in the last section, or one misfiling is reported twice.
|
|
96
|
+
$end = $lines.Count
|
|
97
|
+
for ($i = $HeadingIndex + 1; $i -lt $lines.Count; $i++) {
|
|
98
|
+
if ($lines[$i] -match '^## \[' -or $lines[$i] -ceq '-->') { $end = $i; break }
|
|
99
|
+
}
|
|
100
|
+
if ($end -le $HeadingIndex + 1) { return @() }
|
|
101
|
+
return $lines[($HeadingIndex + 1)..($end - 1)]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
Write-Output "Ledger integrity: $LEDGER"
|
|
105
|
+
Write-Output ''
|
|
106
|
+
|
|
107
|
+
# --- 1. sections exist, in canonical order --------------------------------------------
|
|
108
|
+
$headings = @()
|
|
109
|
+
foreach ($s in $SECTIONS) {
|
|
110
|
+
$idx = Get-SectionHeadingLine $s
|
|
111
|
+
if ($idx -lt 0) {
|
|
112
|
+
Write-Output "✗ missing section: ## [$s] Phase Issues"
|
|
113
|
+
$script:FAIL = 1
|
|
114
|
+
} else {
|
|
115
|
+
$headings += $idx
|
|
116
|
+
Write-Output (" ✓ [{0,-9}] heading at line {1}" -f $s, ($idx + 1))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if ($headings.Count -eq $SECTIONS.Count) {
|
|
120
|
+
$sorted = $headings | Sort-Object
|
|
121
|
+
if (($headings -join ',') -ne ($sorted -join ',')) {
|
|
122
|
+
Write-Output '✗ sections are not in canonical order'
|
|
123
|
+
$script:FAIL = 1
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# --- 2. no orphaned entries (entry outside every section) -----------------------------
|
|
128
|
+
$lastHeading = -1
|
|
129
|
+
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
|
|
130
|
+
if ($lines[$i] -match '^## \[.*\] Phase Issues$') { $lastHeading = $i; break }
|
|
131
|
+
}
|
|
132
|
+
$sentinel = -1
|
|
133
|
+
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
|
|
134
|
+
if ($lines[$i] -ceq '-->') { $sentinel = $i; break }
|
|
135
|
+
}
|
|
136
|
+
if ($sentinel -ge 0 -and $lastHeading -ge 0 -and $sentinel -gt $lastHeading) {
|
|
137
|
+
# ⚠️ `$a..$b` with $a > $b yields a DESCENDING range in PowerShell, and indexing
|
|
138
|
+
# past the end throws — where Bash's `awk 'NR>s'` simply yields nothing. So the
|
|
139
|
+
# empty range has to be tested for rather than relied on to be empty.
|
|
140
|
+
$orphan = @()
|
|
141
|
+
if ($sentinel + 1 -le $lines.Count - 1) {
|
|
142
|
+
$orphan = @($lines[($sentinel + 1)..($lines.Count - 1)] | Where-Object { $_ -match '^- \[[ x]\] ISS-' })
|
|
143
|
+
}
|
|
144
|
+
if ($orphan.Count -gt 0) {
|
|
145
|
+
Write-Output "✗ orphaned entry after the end-of-file sentinel (line $($sentinel + 1)):"
|
|
146
|
+
foreach ($o in $orphan) {
|
|
147
|
+
$trimmed = if ($o.Length -gt 90) { $o.Substring(0, 90) } else { $o }
|
|
148
|
+
Write-Output " $trimmed"
|
|
149
|
+
}
|
|
150
|
+
Write-Output ' → it belongs to no section; move it into its own section'
|
|
151
|
+
$script:FAIL = 1
|
|
152
|
+
} else {
|
|
153
|
+
Write-Output ' ✓ no entries appended past the end-of-file sentinel'
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# --- 3. statistics table matches actual counts ----------------------------------------
|
|
158
|
+
Write-Output ''
|
|
159
|
+
foreach ($s in $SECTIONS) {
|
|
160
|
+
$h = Get-SectionHeadingLine $s
|
|
161
|
+
if ($h -lt 0) { continue }
|
|
162
|
+
$body = Get-SectionBody $h
|
|
163
|
+
$res = @($body | Where-Object { $_ -match '^- \[x\] ISS-' }).Count
|
|
164
|
+
$pen = @($body | Where-Object { $_ -match '^- \[ \] ISS-' }).Count
|
|
165
|
+
$tot = $res + $pen
|
|
166
|
+
|
|
167
|
+
$row = $lines | Where-Object { $_ -match "^\| \[$s\]" } | Select-Object -First 1
|
|
168
|
+
$cells = $row -split '\|'
|
|
169
|
+
$tTot = if ($cells.Count -gt 2) { $cells[2].Trim() } else { '' }
|
|
170
|
+
$tRes = if ($cells.Count -gt 3) { $cells[3].Trim() } else { '' }
|
|
171
|
+
$tPen = if ($cells.Count -gt 4) { $cells[4].Trim() } else { '' }
|
|
172
|
+
|
|
173
|
+
if ($tot -eq [int]$tTot -and $res -eq [int]$tRes -and $pen -eq [int]$tPen) {
|
|
174
|
+
Write-Output (" ✓ [{0,-9}] actual {1}/{2}/{3} = table" -f $s, $tot, $res, $pen)
|
|
175
|
+
} else {
|
|
176
|
+
Write-Output (" ✗ [{0,-9}] actual {1}/{2}/{3} ≠ table {4}/{5}/{6}" -f $s, $tot, $res, $pen, $tTot, $tRes, $tPen)
|
|
177
|
+
# Direction matters: the two directions have different root causes and different
|
|
178
|
+
# correct fixes. "Fix the table" is the WRONG advice for the over-count direction
|
|
179
|
+
# — there the entry was never written, and the fix is to write it.
|
|
180
|
+
if ([int]$tTot -gt $tot) {
|
|
181
|
+
Write-Output (" → table EXCEEDS the section by {0}: the summary claims entries that do not exist." -f ([int]$tTot - $tot))
|
|
182
|
+
Write-Output ' Signature of a SKIPPED WRITE — most often an idempotency guard that searched'
|
|
183
|
+
Write-Output ' the whole file for the ID and matched a mention (the statistics Pending-Items'
|
|
184
|
+
Write-Output ' column, the Last Updated line, a cross-reference) instead of the entry itself.'
|
|
185
|
+
Write-Output " Fix: write the missing entry body (and confirm the guard matches '^- \[[x ]\] ISS-<N>:' )."
|
|
186
|
+
} elseif ([int]$tTot -lt $tot) {
|
|
187
|
+
Write-Output (" → section EXCEEDS the table by {0}: the derived summary was not updated." -f ($tot - [int]$tTot))
|
|
188
|
+
Write-Output ' Fix: update the table (the section entries are authoritative).'
|
|
189
|
+
} else {
|
|
190
|
+
Write-Output ' → counts agree but the resolved/pending split differs; reconcile against the section.'
|
|
191
|
+
}
|
|
192
|
+
$script:FAIL = 1
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# --- 5. Pending-Items column names only entries that exist ----------------------------
|
|
197
|
+
Write-Output ''
|
|
198
|
+
foreach ($s in $SECTIONS) {
|
|
199
|
+
$h = Get-SectionHeadingLine $s
|
|
200
|
+
if ($h -lt 0) { continue }
|
|
201
|
+
$body = Get-SectionBody $h
|
|
202
|
+
$row = $lines | Where-Object { $_ -match "^\| \[$s\]" } | Select-Object -First 1
|
|
203
|
+
$cells = $row -split '\|'
|
|
204
|
+
$column = if ($cells.Count -gt 5) { $cells[5] } else { '' }
|
|
205
|
+
$named = @([regex]::Matches($column, 'ISS-[0-9]+') | ForEach-Object { $_.Value } | Sort-Object -Unique)
|
|
206
|
+
|
|
207
|
+
if ($named.Count -eq 0) {
|
|
208
|
+
Write-Output (" ✓ [{0,-9}] Pending-Items column names no IDs" -f $s)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
# ⚠️ `$miss` is built with a LEADING space per item, matching the Bash side's
|
|
212
|
+
# `miss="$miss $id"`. The result renders as `names ISS-99` — two spaces — and that
|
|
213
|
+
# is the output this script has to reproduce byte-for-byte. Tidying it to a single
|
|
214
|
+
# space here would be an improvement that breaks the contract.
|
|
215
|
+
$miss = ''
|
|
216
|
+
foreach ($id in $named) {
|
|
217
|
+
$pattern = "^- \[ \] $id`:"
|
|
218
|
+
if (-not ($body | Where-Object { $_ -match $pattern })) { $miss += " $id" }
|
|
219
|
+
}
|
|
220
|
+
if ($miss -eq '') {
|
|
221
|
+
Write-Output (" ✓ [{0,-9}] every named pending ID exists as an entry" -f $s)
|
|
222
|
+
} else {
|
|
223
|
+
Write-Output (" ✗ [{0,-9}] Pending-Items names {1} but no such pending entry exists in this section" -f $s, $miss)
|
|
224
|
+
Write-Output ' → the summary names an entry the file does not contain — a skipped or lost write,'
|
|
225
|
+
Write-Output ' not a formatting slip. Write the entry body, or correct the column if it was over-bumped.'
|
|
226
|
+
$script:FAIL = 1
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# --- 4b. a completed hand-off names an entry that EXISTS in its target section --------
|
|
231
|
+
# ISS-110 / ISS-93 / ISS-90 ③: a decision whose closing line was "hand this to
|
|
232
|
+
# /specpro-tasks --review-issues" created no `[tasks]` entry — so that command read an
|
|
233
|
+
# EMPTY queue and exited having done nothing, while the decision read as complete. The
|
|
234
|
+
# channel was never the problem (登记即路由); what was missing was the OBLIGATION to write
|
|
235
|
+
# the entry, and a point where its absence stops something. This is that point.
|
|
236
|
+
#
|
|
237
|
+
# ⚠️ LINE-START anchor, never a full-text match (TOOL-009): the ledger is full of prose
|
|
238
|
+
# ABOUT hand-offs and a check that matched a mention would fire on the discussion.
|
|
239
|
+
# ⚠️ Only `[x]` entries — a `[ ]` entry has not handed anything off yet.
|
|
240
|
+
Write-Output ''
|
|
241
|
+
$handoffs = @()
|
|
242
|
+
$cur = ''
|
|
243
|
+
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
244
|
+
$ln = $lines[$i]
|
|
245
|
+
if ($ln -match '^- \[[ x]\] ISS-[0-9]+:') { $cur = $ln }
|
|
246
|
+
if ($ln -match '^ \*\*移交\*\*: \[[a-z-]+\] → ISS-[0-9]+') {
|
|
247
|
+
if ($cur -match '^- \[x\]') {
|
|
248
|
+
$handoffs += [pscustomobject]@{ LineNo = $i + 1; Entry = $cur; Handoff = $ln }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if ($handoffs.Count -eq 0) {
|
|
253
|
+
Write-Output ' ✓ no completed entry carries a hand-off line (nothing to resolve)'
|
|
254
|
+
} else {
|
|
255
|
+
$bad = 0
|
|
256
|
+
foreach ($h in $handoffs) {
|
|
257
|
+
$target = [regex]::Match($h.Handoff, '\[([a-z-]+)\]').Groups[1].Value
|
|
258
|
+
$hid = [regex]::Match($h.Handoff, 'ISS-[0-9]+').Value
|
|
259
|
+
$src = [regex]::Match($h.Entry, 'ISS-[0-9]+').Value
|
|
260
|
+
if (-not ($SECTIONS -contains $target)) {
|
|
261
|
+
Write-Output (" ✗ hand-off at line {0} targets [{1}], which the contract does not define" -f $h.LineNo, $target)
|
|
262
|
+
Write-Output ' → the named partition has no consumer; a hand-off there is a dead end.'
|
|
263
|
+
$bad = 1
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
$hh = Get-SectionHeadingLine $target
|
|
267
|
+
$body = Get-SectionBody $hh
|
|
268
|
+
if ($body | Where-Object { $_ -match "^- \[[ x]\] $hid`:" }) {
|
|
269
|
+
Write-Output (" ✓ hand-off {0} → [{1}] {2} resolves" -f $src, $target, $hid)
|
|
270
|
+
} else {
|
|
271
|
+
Write-Output (" ✗ hand-off at line {0} names {1} in [{2}], but no such entry exists there" -f $h.LineNo, $hid, $target)
|
|
272
|
+
Write-Output ' → the decision says the work moved; the target section says nothing arrived.'
|
|
273
|
+
Write-Output ' Register the entry in that section (登记即路由), or drop the hand-off line.'
|
|
274
|
+
$bad = 1
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if ($bad -ne 0) { $script:FAIL = 1 }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# --- 4. exactly one trailing newline --------------------------------------------------
|
|
281
|
+
Write-Output ''
|
|
282
|
+
$raw = [System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $LEDGER).Path)
|
|
283
|
+
if ($raw.EndsWith("`n") -and -not $raw.EndsWith("`n`n")) {
|
|
284
|
+
Write-Output ' ✓ single trailing newline'
|
|
285
|
+
} else {
|
|
286
|
+
Write-Output ' ✗ file does not end with exactly one newline'
|
|
287
|
+
Write-Output ' → append-based writers will fuse the next entry onto the previous line'
|
|
288
|
+
$script:FAIL = 1
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# --- 6. every entry block is bounded, and carries exactly one tail ----------------
|
|
292
|
+
# The ledger is position-structured at TWO levels: an entry is decided by position
|
|
293
|
+
# within a section, and a section by position within the file. The checks above see
|
|
294
|
+
# neither: a misplaced tail changes no count, and check 2 looks only at entry HEADERS.
|
|
295
|
+
# Observed three times — twice a `**Resolution**` landed past the end-of-file sentinel,
|
|
296
|
+
# once past a SECTION boundary. Same writer bug, different boundary; each time every
|
|
297
|
+
# check above reported OK.
|
|
298
|
+
#
|
|
299
|
+
# ⚠️ The block-boundary flush is written out twice rather than factored into a helper:
|
|
300
|
+
# a PowerShell scriptblock invoked with `&` runs in a CHILD scope, so `$bounds += …`
|
|
301
|
+
# inside it would be discarded on return — the check would collect nothing and report
|
|
302
|
+
# a clean ledger. Duplication is the cheaper mistake here.
|
|
303
|
+
Write-Output ""
|
|
304
|
+
$bounds = @()
|
|
305
|
+
$entry = $null; $rel = 0; $res = 0; $eid = ""
|
|
306
|
+
|
|
307
|
+
for ($n = 0; $n -lt $lines.Count; $n++) {
|
|
308
|
+
$l = $lines[$n]; $lineNo = $n + 1
|
|
309
|
+
|
|
310
|
+
if ($l -match '^- \[[ x]\] ISS-') {
|
|
311
|
+
if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
|
|
312
|
+
$entry = $l; $rel = 0; $res = 0
|
|
313
|
+
$eid = if ($l -match 'ISS-[0-9]+') { $Matches[0] } else { '?' }
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if ($l -match '^## \[' -or $l -match '^-->') {
|
|
317
|
+
if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
|
|
318
|
+
$entry = $null
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if ($l -match '^ \*\*Related\*\*:') {
|
|
322
|
+
if ($null -eq $entry) { $bounds += "STRAY|$lineNo|Related" } else { $rel++ }
|
|
323
|
+
}
|
|
324
|
+
if ($l -match '^ \*\*Resolution\*\*') {
|
|
325
|
+
if ($null -eq $entry) { $bounds += "STRAY|$lineNo|Resolution" } else { $res++ }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
|
|
329
|
+
|
|
330
|
+
if ($bounds.Count -eq 0) {
|
|
331
|
+
Write-Output " ✓ every entry block is bounded and carries exactly one Related line"
|
|
332
|
+
} else {
|
|
333
|
+
foreach ($b in $bounds) {
|
|
334
|
+
$p = $b -split '\|'
|
|
335
|
+
if ($p[0] -eq 'BLOCK') {
|
|
336
|
+
Write-Output (" ✗ {0}: block carries Related={1} Resolution={2} (want 1 and <=1)" -f $p[1], $p[2], $p[3])
|
|
337
|
+
Write-Output " → the block does not end where it should: a tail line was split off,"
|
|
338
|
+
Write-Output " or a foreign line was absorbed."
|
|
339
|
+
} else {
|
|
340
|
+
Write-Output (" ✗ line {0} carries a {1} OUTSIDE any entry block" -f $p[1], $p[2])
|
|
341
|
+
Write-Output " → a field line was written past a boundary; it belongs to no entry,"
|
|
342
|
+
Write-Output " so no reader will ever attribute it correctly."
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
$script:FAIL = 1
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# --- 7. entry IDs are globally unique ---------------------------------------------------
|
|
349
|
+
# Why this exists (T206 / ISS-166): every consumer ADDRESSES an entry by its ID — the
|
|
350
|
+
# documented marking form is a line-start match on `ISS-<N>`. Two entries sharing one make
|
|
351
|
+
# that address name NEITHER of them, and nothing else reports it: the statistics table
|
|
352
|
+
# still reconciles (bump the count and it agrees), the section checks still pass, the
|
|
353
|
+
# sentinel still holds. The collision is silent until someone reads the whole file — the
|
|
354
|
+
# failure TOOL-011 recorded for its own numbering, in a file since retired while this one
|
|
355
|
+
# inherited none of it.
|
|
356
|
+
#
|
|
357
|
+
# ⚠️ Numeric sort, never a plain lexicographic one: the IDs are variable width, so
|
|
358
|
+
# `ISS-100` sorts BEFORE `ISS-97` character-wise. Same reason "take the next number" must
|
|
359
|
+
# use a numeric sort — see the note in commands/specpro.implement.md.
|
|
360
|
+
Write-Output ''
|
|
361
|
+
$ids = [System.IO.File]::ReadAllLines((Resolve-Path -LiteralPath $LEDGER).Path) |
|
|
362
|
+
Where-Object { $_ -match '^- \[[x ]\] ISS-[0-9]+:' } |
|
|
363
|
+
ForEach-Object { [int]([regex]::Match($_, 'ISS-([0-9]+):').Groups[1].Value) }
|
|
364
|
+
$dups = $ids | Group-Object | Where-Object { $_.Count -gt 1 } | ForEach-Object { $_.Name }
|
|
365
|
+
if (-not $dups) {
|
|
366
|
+
Write-Output ' ✓ entry IDs are unique'
|
|
367
|
+
} else {
|
|
368
|
+
Write-Output (" ✗ these entry IDs are used more than once:" + (($dups | Sort-Object { [int]$_ }) -join ' ' | ForEach-Object { " $_" }))
|
|
369
|
+
Write-Output ' → an ID is how a --review-issues run ADDRESSES an entry; two entries'
|
|
370
|
+
Write-Output ' sharing one mean the address names neither of them, and the counts'
|
|
371
|
+
Write-Output ' still reconcile, so nothing else would have reported this.'
|
|
372
|
+
$script:FAIL = 1
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
# --- verdict ---------------------------------------------------------------------------
|
|
376
|
+
Write-Output ''
|
|
377
|
+
if ($script:FAIL -eq 0) {
|
|
378
|
+
Write-Output '✓ ledger integrity: all invariants hold'
|
|
379
|
+
exit 0
|
|
380
|
+
} else {
|
|
381
|
+
Write-Output '✗ ledger integrity: VIOLATIONS FOUND (see above)'
|
|
382
|
+
exit 1
|
|
383
|
+
}
|