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,133 @@
|
|
|
1
|
+
# Cumulative-record verifier — PowerShell twin of verify-cumulative-records.sh.
|
|
2
|
+
#
|
|
3
|
+
# FR-008 requires the two implementations to be stdout/stderr/exit-code byte-identical;
|
|
4
|
+
# tools/install/tests/test_script_parity.py asserts it on every fixture.
|
|
5
|
+
#
|
|
6
|
+
# The rationale, the measured failure it comes from, the two-sided DERIVATION design (no
|
|
7
|
+
# registries — that is what makes it fixtureable without editing the subject), and the
|
|
8
|
+
# stated coverage limits are argued in the `.sh` twin. This file is the same program, and
|
|
9
|
+
# the two are changed together.
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = 'Continue'
|
|
12
|
+
|
|
13
|
+
# Base resolution mirrors the shell twin: an explicit override for fixtures, else the
|
|
14
|
+
# repository root two levels up from this script.
|
|
15
|
+
$Base = if ($env:RECORDS_HOOK_BASE) { $env:RECORDS_HOOK_BASE }
|
|
16
|
+
else { (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path }
|
|
17
|
+
|
|
18
|
+
$script:Hits = 0
|
|
19
|
+
function Write-Ok { param([string]$m) Write-Output " ✓ $m" }
|
|
20
|
+
function Write-Hit { param([string]$m) $script:Hits++; Write-Output " ✗ $m" }
|
|
21
|
+
function Write-Skip { param([string]$m) Write-Output " ⊘ $m" }
|
|
22
|
+
|
|
23
|
+
Write-Output "Cumulative-record check: $Base"
|
|
24
|
+
|
|
25
|
+
# Resolve a destination variable to a path SUFFIX by reading its binding in the same
|
|
26
|
+
# document: drop up to and including the last `$`, then cut at the first `/`. Only the
|
|
27
|
+
# suffix is compared, so the binding's own base variable never has to be resolved.
|
|
28
|
+
function Get-DestSuffix {
|
|
29
|
+
param([string]$Doc, [string]$Var)
|
|
30
|
+
$path = Join-Path $Base $Doc
|
|
31
|
+
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return '' }
|
|
32
|
+
$m = [regex]::Match((Get-Content -LiteralPath $path -Raw), "^\s*$([regex]::Escape($Var))=""([^""]*)""", 'Multiline')
|
|
33
|
+
if (-not $m.Success) { return '' }
|
|
34
|
+
$binding = $m.Groups[1].Value
|
|
35
|
+
$idx = $binding.LastIndexOf('$')
|
|
36
|
+
if ($idx -lt 0) { return '' }
|
|
37
|
+
$binding = $binding.Substring($idx + 1)
|
|
38
|
+
$slash = $binding.IndexOf('/')
|
|
39
|
+
if ($slash -lt 0) { return '' }
|
|
40
|
+
return $binding.Substring($slash + 1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# -----------------------------------------------------------------------------------------
|
|
44
|
+
# 1. Records — every file under specs/ that declares itself cumulative
|
|
45
|
+
# -----------------------------------------------------------------------------------------
|
|
46
|
+
Write-Output " [1] cross-round records (self-declared: ``**Cumulative**: …``)"
|
|
47
|
+
$records = @()
|
|
48
|
+
$specsDir = Join-Path $Base 'specs'
|
|
49
|
+
if (Test-Path -LiteralPath $specsDir -PathType Container) {
|
|
50
|
+
$records = Get-ChildItem -LiteralPath $specsDir -Recurse -Filter *.md -File |
|
|
51
|
+
Where-Object { (Get-Content -LiteralPath $_.FullName -Raw) -cmatch '(?m)^\*\*Cumulative\*\*: ' } |
|
|
52
|
+
ForEach-Object { $_.FullName.Substring($Base.Length + 1) -replace '\\', '/' } |
|
|
53
|
+
Sort-Object
|
|
54
|
+
}
|
|
55
|
+
if ($records.Count -eq 0) {
|
|
56
|
+
# A SKIP, not a hit: this script ships with the tool, and a user project may legitimately
|
|
57
|
+
# have no cross-round record at all. The cost is real and stated — un-declaring a record
|
|
58
|
+
# turns this check off for it — which is why the declaration lives in the record's header.
|
|
59
|
+
Write-Skip "no file under specs/ declares itself cumulative — nothing is proven"
|
|
60
|
+
} else {
|
|
61
|
+
foreach ($r in $records) { Write-Ok "record: $r" }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# -----------------------------------------------------------------------------------------
|
|
65
|
+
# 2. Rebuild sites — a `cp` in commands/ whose destination is a shell variable
|
|
66
|
+
# -----------------------------------------------------------------------------------------
|
|
67
|
+
# The optional closing quote is load-bearing — see the shell twin.
|
|
68
|
+
Write-Output " [2] wholesale-rebuild sites (``cp`` to a shell variable in commands/)"
|
|
69
|
+
$rebuildRe = '^\s*cp\s+.*\$[A-Za-z_][A-Za-z0-9_]*["'']?\s*$'
|
|
70
|
+
$varRe = '\$[A-Za-z_][A-Za-z0-9_]*'
|
|
71
|
+
$sites = @()
|
|
72
|
+
$cmdDir = Join-Path $Base 'commands'
|
|
73
|
+
if (Test-Path -LiteralPath $cmdDir -PathType Container) {
|
|
74
|
+
$files = Get-ChildItem -LiteralPath $cmdDir -Filter *.md -File | Sort-Object Name
|
|
75
|
+
foreach ($f in $files) {
|
|
76
|
+
$rel = $f.FullName.Substring($Base.Length + 1) -replace '\\', '/'
|
|
77
|
+
$n = 0
|
|
78
|
+
foreach ($line in (Get-Content -LiteralPath $f.FullName)) {
|
|
79
|
+
$n++
|
|
80
|
+
if ($line -notmatch $rebuildRe) { continue }
|
|
81
|
+
$sites += ,@($rel, $n, ($line -replace '^\s+', ''))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
foreach ($s in $sites) {
|
|
86
|
+
$rel = $s[0]; $num = $s[1]; $text = $s[2]
|
|
87
|
+
$vars = [regex]::Matches($text, $varRe)
|
|
88
|
+
$var = if ($vars.Count -gt 0) { $vars[$vars.Count - 1].Value.TrimStart('$') } else { '' }
|
|
89
|
+
$suffix = Get-DestSuffix -Doc $rel -Var $var
|
|
90
|
+
if ([string]::IsNullOrEmpty($suffix)) {
|
|
91
|
+
Write-Hit "rebuild site: cannot resolve `$$var in $rel`:$num — nothing is proven about it"
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
Write-Ok "rebuild site: $rel`:$num → $suffix"
|
|
95
|
+
}
|
|
96
|
+
if ($sites.Count -eq 0) {
|
|
97
|
+
Write-Skip 'no `cp … $VAR` line found in commands/ — nothing is proven about rebuild sites'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# -----------------------------------------------------------------------------------------
|
|
101
|
+
# 3. The proposition: no record sits where a rebuild lands
|
|
102
|
+
# -----------------------------------------------------------------------------------------
|
|
103
|
+
Write-Output " [3] no cross-round record sits on a rebuild path"
|
|
104
|
+
$checked = 0
|
|
105
|
+
foreach ($s in $sites) {
|
|
106
|
+
$rel = $s[0]; $text = $s[2]
|
|
107
|
+
$vars = [regex]::Matches($text, $varRe)
|
|
108
|
+
$var = if ($vars.Count -gt 0) { $vars[$vars.Count - 1].Value.TrimStart('$') } else { '' }
|
|
109
|
+
$suffix = Get-DestSuffix -Doc $rel -Var $var
|
|
110
|
+
if ([string]::IsNullOrEmpty($suffix)) { continue }
|
|
111
|
+
foreach ($r in $records) {
|
|
112
|
+
$checked++
|
|
113
|
+
if ($r.EndsWith("/$suffix") -or $r -ceq $suffix) {
|
|
114
|
+
Write-Hit "a cross-round record lives in an artifact rebuilt wholesale — $r (rebuilt by $rel)"
|
|
115
|
+
} else {
|
|
116
|
+
Write-Ok "not on a rebuild path: $r"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if ($checked -eq 0) {
|
|
121
|
+
Write-Skip 'no (record × rebuild site) pair to compare — nothing is proven'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# -----------------------------------------------------------------------------------------
|
|
125
|
+
# Coverage, stated rather than implied — see the shell twin for the full note.
|
|
126
|
+
# -----------------------------------------------------------------------------------------
|
|
127
|
+
Write-Output ""
|
|
128
|
+
if ($script:Hits -gt 0) {
|
|
129
|
+
Write-Output "✗ cumulative records: $($script:Hits) violation(s)"
|
|
130
|
+
exit 1
|
|
131
|
+
}
|
|
132
|
+
Write-Output "✓ cumulative records: no record sits on a rebuild path"
|
|
133
|
+
exit 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
#
|
|
3
|
+
# Deliverables-tracked verifier — PowerShell twin of scripts/bash/verify-deliverables-tracked.sh.
|
|
4
|
+
#
|
|
5
|
+
# Same stdout, same exit code, per FR-008. The rationale (the three deliverable
|
|
6
|
+
# classes and the ISS-75 scope rule) lives in the bash side's header; keep the two in step.
|
|
7
|
+
#
|
|
8
|
+
# `.Contains`-style checks and explicit loops stand in for the bash `case`/parameter
|
|
9
|
+
# expansions; brace expansion is re-implemented here as prefix/suffix splitting —
|
|
10
|
+
# one level, exactly like the bash side.
|
|
11
|
+
|
|
12
|
+
$Repo = git rev-parse --show-toplevel 2>$null
|
|
13
|
+
if (-not $Repo -or -not (Test-Path $Repo)) {
|
|
14
|
+
Write-Output "deliverables-tracked: not inside a git repository"
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
$Repo = (Resolve-Path $Repo).Path
|
|
18
|
+
Write-Output "deliverables-tracked: $Repo"
|
|
19
|
+
$Fail = $false
|
|
20
|
+
|
|
21
|
+
# --- class ② — deliverable source dirs in THIS repo --------------------------
|
|
22
|
+
$SrcDirs = @('commands', 'templates', 'scripts', 'tools')
|
|
23
|
+
$untracked = git -C $Repo ls-files --others --exclude-standard -- @SrcDirs
|
|
24
|
+
if ($untracked) {
|
|
25
|
+
$Fail = $true
|
|
26
|
+
Write-Output ("✗ class ② — untracked files under deliverable source dirs ({0}):" -f ($SrcDirs -join ' '))
|
|
27
|
+
$untracked | ForEach-Object { Write-Output " $_" }
|
|
28
|
+
} else {
|
|
29
|
+
Write-Output ("✓ class ② — source dirs ({0}): every file tracked" -f ($SrcDirs -join ' '))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# --- class ③ — artifact repos (specs/ · docs/) --------------------------------
|
|
33
|
+
foreach ($sub in @('specs', 'docs')) {
|
|
34
|
+
if (Test-Path (Join-Path $Repo "$sub/.git")) {
|
|
35
|
+
$u = git -C (Join-Path $Repo $sub) ls-files --others --exclude-standard
|
|
36
|
+
if ($u) {
|
|
37
|
+
$Fail = $true
|
|
38
|
+
Write-Output "✗ class ③ — untracked files in the $sub/ artifact repo:"
|
|
39
|
+
$u | ForEach-Object { Write-Output " $_" }
|
|
40
|
+
} else {
|
|
41
|
+
Write-Output "✓ class ③ — artifact repo $sub/: every file tracked"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# --- class ① — Location paths named by CHECKED tasks --------------------------
|
|
47
|
+
# Three-conditioned verdict (see the bash side): tracked -> verified; on disk but
|
|
48
|
+
# untracked -> real violation; ignored BY DESIGN (derived deploy mirror) -> skipped;
|
|
49
|
+
# absent from disk -> skipped. Backtick segments include descriptions' code words,
|
|
50
|
+
# so "untracked" alone accuses non-paths.
|
|
51
|
+
$tasks = Join-Path $Repo 'specs/tasks.md'
|
|
52
|
+
$checkedN = 0
|
|
53
|
+
$skippedN = 0
|
|
54
|
+
if (Test-Path $tasks) {
|
|
55
|
+
$lines = Select-String -Path $tasks -Pattern '^- \[x\] T[0-9]' | ForEach-Object { $_.Line }
|
|
56
|
+
foreach ($line in $lines) {
|
|
57
|
+
$segs = [regex]::Matches($line, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value }
|
|
58
|
+
foreach ($seg in $segs) {
|
|
59
|
+
if (-not $seg) { continue }
|
|
60
|
+
if ($seg -match ' ') { $skippedN++; continue } # prose/note
|
|
61
|
+
if ($seg.StartsWith('/')) { $skippedN++; continue } # absolute
|
|
62
|
+
|
|
63
|
+
$repoPath = $Repo
|
|
64
|
+
$rel = $seg
|
|
65
|
+
if ($seg.StartsWith('specs/')) { $repoPath = Join-Path $Repo 'specs'; $rel = $seg.Substring(6) }
|
|
66
|
+
elseif ($seg.StartsWith('docs/')) { $repoPath = Join-Path $Repo 'docs'; $rel = $seg.Substring(5) }
|
|
67
|
+
|
|
68
|
+
# A segment that reduces to an empty path (`specs/` bare) is not a path —
|
|
69
|
+
# feeding it to git only produces a pathspec error (T254).
|
|
70
|
+
if ([string]::IsNullOrEmpty($rel)) { $skippedN++; continue }
|
|
71
|
+
|
|
72
|
+
$rels = @($rel)
|
|
73
|
+
if ($rel -match '\{([^{}]*)\}') {
|
|
74
|
+
$openIdx = $rel.IndexOf('{')
|
|
75
|
+
$closeIdx = $rel.IndexOf('}')
|
|
76
|
+
$pre = $rel.Substring(0, $openIdx)
|
|
77
|
+
$mid = $rel.Substring($openIdx + 1, $closeIdx - $openIdx - 1)
|
|
78
|
+
$suf = $rel.Substring($closeIdx + 1)
|
|
79
|
+
# Empty branches (e.g. `{}` / trailing comma) yield zero words on the
|
|
80
|
+
# bash side (word-splitting drops them) — filter here to stay in step.
|
|
81
|
+
$rels = @($mid.Split(',') | Where-Object { $_ } | ForEach-Object { "$pre$_$suf" })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
foreach ($one in $rels) {
|
|
85
|
+
if ([string]::IsNullOrEmpty($one)) { $skippedN++; continue }
|
|
86
|
+
# Ignored BY DESIGN ⇒ skipped (T254 / ISS-235): a derived deploy mirror
|
|
87
|
+
# (`.claude/`, `.specpro/`) is gitignored on purpose; the deliverable is
|
|
88
|
+
# its source side, covered by the same Location's other path.
|
|
89
|
+
git -C $repoPath check-ignore -q -- $one *> $null
|
|
90
|
+
if ($LASTEXITCODE -eq 0) { $skippedN++ }
|
|
91
|
+
else {
|
|
92
|
+
git -C $repoPath ls-files --error-unmatch -- $one *> $null
|
|
93
|
+
if ($LASTEXITCODE -eq 0) { $checkedN++ }
|
|
94
|
+
elseif (Test-Path -LiteralPath (Join-Path $repoPath $one) -PathType Leaf) {
|
|
95
|
+
$Fail = $true
|
|
96
|
+
if ($rels.Count -gt 1) { Write-Output "✗ class ① — task Location file exists but is NOT tracked: $seg (expanded: $one)" }
|
|
97
|
+
else { Write-Output "✗ class ① — task Location file exists but is NOT tracked: $seg" }
|
|
98
|
+
}
|
|
99
|
+
else { $skippedN++ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
Write-Output "✓ class ① — task Locations: $checkedN path(s) verified tracked · $skippedN skipped (not on disk / not a path / ignored-by-design)"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (-not $Fail) {
|
|
108
|
+
Write-Output "✓ deliverables-tracked: every deliverable path is tracked"
|
|
109
|
+
exit 0
|
|
110
|
+
}
|
|
111
|
+
Write-Output "✗ deliverables-tracked: untracked deliverables found — commit them or fix the criterion"
|
|
112
|
+
exit 1
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
#
|
|
3
|
+
# Deployment-drift verifier — PowerShell twin of scripts/bash/verify-deployment.sh.
|
|
4
|
+
#
|
|
5
|
+
# Same stdout, same stderr, same exit code, by the contract in
|
|
6
|
+
# `specs/contracts/dev-shellcheck-tool.md` and FR-008. The doc-comment below is the
|
|
7
|
+
# shared rationale; keep it in step with the bash side.
|
|
8
|
+
#
|
|
9
|
+
# Why this exists: 宪法 Q1 requires that mirrors not diverge unnoticed, and 宪法 1.1
|
|
10
|
+
# requires deployment to be the closing step of a landing. The enforcement point is
|
|
11
|
+
# `scripts/git-hooks/pre-commit` §3.
|
|
12
|
+
#
|
|
13
|
+
# 核验强度按目标形态确定 (plan.md → Constraint 1); the form is judged FIRST:
|
|
14
|
+
# same — byte-for-byte copy ⇒ Compare-Object style diff, zero output expected
|
|
15
|
+
# derived — produced by a rule ⇒ reproducible-check criterion, NOT implemented here
|
|
16
|
+
#
|
|
17
|
+
# ⚠️ A `derived` group FAILS loudly, and so does a MISSING mirror. Neither is skipped:
|
|
18
|
+
# an unchecked group that reports success is indistinguishable from a clean one.
|
|
19
|
+
|
|
20
|
+
# No `$ErrorActionPreference = 'Stop'` for the diff calls: this script reads their exit
|
|
21
|
+
# codes, and Stop would end it before the reading happens (the same carve-out the bash
|
|
22
|
+
# side documents).
|
|
23
|
+
|
|
24
|
+
# ⚠️ **Position-independent, and the result is TESTED** (T180 / `ISS-124`): the previous
|
|
25
|
+
# `$PSScriptRoot/../..` is correct only from the SOURCE layout — from the deployed mirror
|
|
26
|
+
# it lands one level short. ⚠️ A mirror table read against the wrong tree still prints
|
|
27
|
+
# "in sync", which is indistinguishable from a real pass; and this script is what the
|
|
28
|
+
# pre-commit hook calls, so the wrong tree is the likely one.
|
|
29
|
+
#
|
|
30
|
+
# The sentinel tests the RESULT, not the method — including for an override.
|
|
31
|
+
function Test-RepoRoot([string]$p) {
|
|
32
|
+
(Test-Path (Join-Path $p 'commands')) -and
|
|
33
|
+
(Test-Path (Join-Path $p 'templates')) -and
|
|
34
|
+
(Test-Path (Join-Path $p 'scripts'))
|
|
35
|
+
}
|
|
36
|
+
function Resolve-Base {
|
|
37
|
+
$t = git rev-parse --show-toplevel 2>$null
|
|
38
|
+
if ($t -and (Test-RepoRoot $t)) { return $t }
|
|
39
|
+
foreach ($cand in @((Join-Path $PSScriptRoot '..' '..'), (Join-Path $PSScriptRoot '..' '..' '..'))) {
|
|
40
|
+
if (Test-Path $cand) {
|
|
41
|
+
$p = (Resolve-Path $cand).Path
|
|
42
|
+
if (Test-RepoRoot $p) { return $p }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return $null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
$Base = if ($env:DEPLOY_HOOK_BASE) { $env:DEPLOY_HOOK_BASE } else { Resolve-Base }
|
|
49
|
+
|
|
50
|
+
if (-not (Test-RepoRoot $Base)) {
|
|
51
|
+
$shown = if ($Base) { $Base } else { '<unresolved>' }
|
|
52
|
+
[Console]::Error.WriteLine("✗ BASE is not a repository root: '$shown'")
|
|
53
|
+
[Console]::Error.WriteLine(" Expected it to contain commands/ , templates/ and scripts/ .")
|
|
54
|
+
if ($env:DEPLOY_HOOK_BASE) {
|
|
55
|
+
[Console]::Error.WriteLine(" Source: the DEPLOY_HOOK_BASE override.")
|
|
56
|
+
} else {
|
|
57
|
+
[Console]::Error.WriteLine(" Source: auto-resolution (git toplevel, then the script's two possible depths).")
|
|
58
|
+
}
|
|
59
|
+
[Console]::Error.WriteLine(" ⚠️ Every group below would be compared against a tree this script never read —")
|
|
60
|
+
[Console]::Error.WriteLine(" an absent mirror and an in-sync mirror are the same output otherwise.")
|
|
61
|
+
exit 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# 源 | 镜像 | 形态
|
|
65
|
+
$Pairs = @(
|
|
66
|
+
@{ Src = 'commands'; Dst = '.claude/skills'; Form = 'derived' }
|
|
67
|
+
@{ Src = 'templates'; Dst = '.specpro/templates'; Form = 'same' }
|
|
68
|
+
@{ Src = 'scripts/bash'; Dst = '.specpro/scripts/bash'; Form = 'same' }
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ⚠️ `powershell` is excluded by DECISION, not neglect: the mirror exists so THIS
|
|
72
|
+
# repository can run its own toolchain, and this repository is not Windows. The
|
|
73
|
+
# PowerShell implementations reach user projects through the CLI's asset snapshot, which
|
|
74
|
+
# has its own verifier (Constraint 4).
|
|
75
|
+
$ExcludedInScripts = @('powershell', 'git-hooks', 'install-git-hooks.sh')
|
|
76
|
+
|
|
77
|
+
$Fail = 0
|
|
78
|
+
Write-Output "Deployment drift: $Base"
|
|
79
|
+
Write-Output ""
|
|
80
|
+
|
|
81
|
+
foreach ($pair in $Pairs) {
|
|
82
|
+
$src = Join-Path $Base $pair.Src
|
|
83
|
+
$dst = Join-Path $Base $pair.Dst
|
|
84
|
+
Write-Output (" [{0}] {1} → {2}" -f $pair.Form, $pair.Src, $pair.Dst)
|
|
85
|
+
|
|
86
|
+
if (-not (Test-Path -LiteralPath $src)) {
|
|
87
|
+
Write-Output " ✗ source does not exist: $src"
|
|
88
|
+
Write-Output " → the table names a group that is not there; fix the table or restore the source."
|
|
89
|
+
$Fail = 1
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (-not (Test-Path -LiteralPath $dst -PathType Container)) {
|
|
94
|
+
Write-Output " ✗ mirror missing: $dst"
|
|
95
|
+
Write-Output " → deploy the source, or add the group to EXCLUDED_* by name with a reason."
|
|
96
|
+
Write-Output " A missing target MUST NOT be skipped: an absent group and a clean one look identical."
|
|
97
|
+
$Fail = 1
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if ($pair.Form -eq 'derived') {
|
|
102
|
+
# Reproducible check (FR-007 / 宪法 1.1 T3): same source + same base class ⇒
|
|
103
|
+
# same output. Two installs of the CURRENT source into two temp dirs must agree
|
|
104
|
+
# byte-for-byte (determinism), and the mirror must equal the first (sync) — a
|
|
105
|
+
# stale mirror fails exactly like a drifted copy. No installer ⇒ UNVERIFIED ⇒
|
|
106
|
+
# FAIL, never skip.
|
|
107
|
+
$Py = $null
|
|
108
|
+
foreach ($pycand in @(
|
|
109
|
+
(Join-Path $Base 'tools/install/.venv/bin/python'),
|
|
110
|
+
(Join-Path $Base 'tools/install/.venv/Scripts/python.exe')
|
|
111
|
+
)) {
|
|
112
|
+
if (Test-Path -LiteralPath $pycand -PathType Leaf) { $Py = $pycand; break }
|
|
113
|
+
}
|
|
114
|
+
if ($null -eq $Py) {
|
|
115
|
+
Write-Output " ✗ derived check cannot run: no install CLI venv under tools/install/."
|
|
116
|
+
Write-Output " → the derived mirror is produced by this repo's own CLI; without it this"
|
|
117
|
+
Write-Output " group is UNVERIFIED, and reporting success would be a false pass."
|
|
118
|
+
$Fail = 1
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
$tmp1 = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
|
|
122
|
+
$tmp2 = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
|
|
123
|
+
$instOk = $true
|
|
124
|
+
Push-Location (Join-Path $Base 'tools/install')
|
|
125
|
+
try {
|
|
126
|
+
& $Py -c "from specpro_cli.cli.entry import app; app()" init $tmp1 --agent claude --source $Base *> $null
|
|
127
|
+
if ($LASTEXITCODE -ne 0) { $instOk = $false }
|
|
128
|
+
& $Py -c "from specpro_cli.cli.entry import app; app()" init $tmp2 --agent claude --source $Base *> $null
|
|
129
|
+
if ($LASTEXITCODE -ne 0) { $instOk = $false }
|
|
130
|
+
} finally {
|
|
131
|
+
Pop-Location
|
|
132
|
+
}
|
|
133
|
+
$fresh1 = Join-Path $tmp1 '.claude/skills'
|
|
134
|
+
$fresh2 = Join-Path $tmp2 '.claude/skills'
|
|
135
|
+
if (-not $instOk -or -not (Test-Path -LiteralPath $fresh1 -PathType Container) -or -not (Test-Path -LiteralPath $fresh2 -PathType Container)) {
|
|
136
|
+
Write-Output " ✗ derived check cannot run: the install CLI failed to produce .claude/skills."
|
|
137
|
+
Write-Output " → fix the CLI or its venv; until then this group is UNVERIFIED (never a silent pass)."
|
|
138
|
+
$Fail = 1
|
|
139
|
+
if (Test-Path -LiteralPath $tmp1) { Remove-Item -Recurse -Force -LiteralPath $tmp1 }
|
|
140
|
+
if (Test-Path -LiteralPath $tmp2) { Remove-Item -Recurse -Force -LiteralPath $tmp2 }
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
$drift = @()
|
|
144
|
+
# ⚠️ The comma operator is LOAD-BEARING: `@(@($a,$b))` flattens in PowerShell
|
|
145
|
+
# (the inner array unrolls into the outer), and then `$side[0]` is the FIRST
|
|
146
|
+
# CHARACTER of a path — `Get-ChildItem -Recurse /` — a whole-filesystem scan.
|
|
147
|
+
$sides = ,@($fresh1, $fresh2)
|
|
148
|
+
foreach ($side in $sides) {
|
|
149
|
+
$a = $side[0]
|
|
150
|
+
$b = $side[1]
|
|
151
|
+
$aFiles = Get-ChildItem -LiteralPath $a -Recurse -File -Force | Where-Object { $_.Name -ne '.DS_Store' }
|
|
152
|
+
$bFiles = Get-ChildItem -LiteralPath $b -Recurse -File -Force | Where-Object { $_.Name -ne '.DS_Store' }
|
|
153
|
+
foreach ($f in $aFiles) {
|
|
154
|
+
$rel = $f.FullName.Substring($a.Length).TrimStart('\', '/')
|
|
155
|
+
if (-not (Test-Path -LiteralPath (Join-Path $b $rel) -PathType Leaf)) { $drift += "x"; break }
|
|
156
|
+
$ha = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash
|
|
157
|
+
$hb = (Get-FileHash -LiteralPath (Join-Path $b $rel) -Algorithm SHA256).Hash
|
|
158
|
+
if ($ha -ne $hb) { $drift += "x"; break }
|
|
159
|
+
}
|
|
160
|
+
if ($drift.Count -eq 0) {
|
|
161
|
+
foreach ($f in $bFiles) {
|
|
162
|
+
$rel = $f.FullName.Substring($b.Length).TrimStart('\', '/')
|
|
163
|
+
if (-not (Test-Path -LiteralPath (Join-Path $a $rel) -PathType Leaf)) { $drift += "x"; break }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if ($drift.Count -gt 0) { break }
|
|
167
|
+
}
|
|
168
|
+
if ($drift.Count -gt 0) {
|
|
169
|
+
Write-Output " ✗ derived: two installs of the same source DISAGREE — output is not reproducible."
|
|
170
|
+
if (Test-Path -LiteralPath $tmp1) { Remove-Item -Recurse -Force -LiteralPath $tmp1 }
|
|
171
|
+
if (Test-Path -LiteralPath $tmp2) { Remove-Item -Recurse -Force -LiteralPath $tmp2 }
|
|
172
|
+
$Fail = 1
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
# Mirror vs fresh install — same message shapes as `diff -rq` (the parity contract).
|
|
176
|
+
$drift = @()
|
|
177
|
+
$fFiles = Get-ChildItem -LiteralPath $fresh1 -Recurse -File -Force | Where-Object { $_.Name -ne '.DS_Store' }
|
|
178
|
+
$mFiles = Get-ChildItem -LiteralPath $dst -Recurse -File -Force | Where-Object { $_.Name -ne '.DS_Store' }
|
|
179
|
+
foreach ($f in $fFiles) {
|
|
180
|
+
$rel = $f.FullName.Substring($fresh1.Length).TrimStart('\', '/')
|
|
181
|
+
$t = Join-Path $dst $rel
|
|
182
|
+
if (-not (Test-Path -LiteralPath $t -PathType Leaf)) {
|
|
183
|
+
$drift += "Only in $fresh1`: $rel"
|
|
184
|
+
} else {
|
|
185
|
+
$ha = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash
|
|
186
|
+
$hb = (Get-FileHash -LiteralPath $t -Algorithm SHA256).Hash
|
|
187
|
+
if ($ha -ne $hb) { $drift += "Files $($f.FullName) and $t differ" }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
foreach ($f in $mFiles) {
|
|
191
|
+
$rel = $f.FullName.Substring($dst.Length).TrimStart('\', '/')
|
|
192
|
+
if (-not (Test-Path -LiteralPath (Join-Path $fresh1 $rel) -PathType Leaf)) {
|
|
193
|
+
$drift += "Only in $dst`: $rel"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (Test-Path -LiteralPath $tmp1) { Remove-Item -Recurse -Force -LiteralPath $tmp1 }
|
|
197
|
+
if (Test-Path -LiteralPath $tmp2) { Remove-Item -Recurse -Force -LiteralPath $tmp2 }
|
|
198
|
+
if ($drift.Count -gt 0) {
|
|
199
|
+
Write-Output " ✗ derived drift (mirror ≠ fresh install of the current source):"
|
|
200
|
+
# Temp prefix normalized away — same reason as the bash side: the drift
|
|
201
|
+
# lines must not depend on where the fresh install landed.
|
|
202
|
+
foreach ($d in $drift) { Write-Output " $($d.Replace($tmp1, '<fresh-install>'))" }
|
|
203
|
+
$Fail = 1
|
|
204
|
+
} else {
|
|
205
|
+
Write-Output " ✓ derived: reproducible (2 installs agree) and in sync with the current source"
|
|
206
|
+
}
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
$srcFiles = Get-ChildItem -LiteralPath $src -Recurse -File -Force |
|
|
211
|
+
Where-Object { $_.Name -ne '.DS_Store' }
|
|
212
|
+
$dstFiles = Get-ChildItem -LiteralPath $dst -Recurse -File -Force |
|
|
213
|
+
Where-Object { $_.Name -ne '.DS_Store' }
|
|
214
|
+
|
|
215
|
+
$drift = @()
|
|
216
|
+
foreach ($f in $srcFiles) {
|
|
217
|
+
$rel = $f.FullName.Substring($src.Length).TrimStart('\', '/')
|
|
218
|
+
$t = Join-Path $dst $rel
|
|
219
|
+
if (-not (Test-Path -LiteralPath $t -PathType Leaf)) {
|
|
220
|
+
# ⚠️ Message shapes mirror `diff -rq`'s own output, absolute paths included.
|
|
221
|
+
# The parity contract is "same stdout", and a hand-built message that reads
|
|
222
|
+
# better still breaks it — the parity test found exactly that on the first run.
|
|
223
|
+
$drift += "Only in $src`: $rel"
|
|
224
|
+
} else {
|
|
225
|
+
$a = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash
|
|
226
|
+
$b = (Get-FileHash -LiteralPath $t -Algorithm SHA256).Hash
|
|
227
|
+
if ($a -ne $b) { $drift += "Files $($f.FullName) and $t differ" }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
foreach ($f in $dstFiles) {
|
|
231
|
+
$rel = $f.FullName.Substring($dst.Length).TrimStart('\', '/')
|
|
232
|
+
if (-not (Test-Path -LiteralPath (Join-Path $src $rel) -PathType Leaf)) {
|
|
233
|
+
$drift += "Only in $dst`: $rel"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if ($drift.Count -gt 0) {
|
|
238
|
+
Write-Output " ✗ drift:"
|
|
239
|
+
foreach ($d in $drift) { Write-Output " $d" }
|
|
240
|
+
$Fail = 1
|
|
241
|
+
} else {
|
|
242
|
+
Write-Output " ✓ byte-identical"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
Write-Output ""
|
|
247
|
+
$scriptsDir = Join-Path $Base 'scripts'
|
|
248
|
+
if (Test-Path -LiteralPath $scriptsDir -PathType Container) {
|
|
249
|
+
$uncovered = @()
|
|
250
|
+
foreach ($entry in Get-ChildItem -LiteralPath $scriptsDir -Force) {
|
|
251
|
+
$name = $entry.Name
|
|
252
|
+
if ($ExcludedInScripts -contains $name) { continue }
|
|
253
|
+
$covered = $false
|
|
254
|
+
foreach ($pair in $Pairs) {
|
|
255
|
+
if ($pair.Src -eq "scripts/$name") { $covered = $true }
|
|
256
|
+
}
|
|
257
|
+
if (-not $covered) { $uncovered += $name }
|
|
258
|
+
}
|
|
259
|
+
if ($uncovered.Count -gt 0) {
|
|
260
|
+
Write-Output " ✗ scripts/ entries that are neither mirrored nor listed as excluded:"
|
|
261
|
+
foreach ($u in $uncovered) { Write-Output " $u" }
|
|
262
|
+
Write-Output " → add a mirror row, or add the name to EXCLUDED_IN_SCRIPTS with a reason."
|
|
263
|
+
Write-Output " Otherwise the next un-mirrored directory joins without anyone noticing."
|
|
264
|
+
$Fail = 1
|
|
265
|
+
} else {
|
|
266
|
+
Write-Output " ✓ every scripts/ entry is either mirrored or listed as excluded"
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
Write-Output ""
|
|
271
|
+
if ($Fail -eq 0) {
|
|
272
|
+
Write-Output "✓ deployment: every group is in sync"
|
|
273
|
+
exit 0
|
|
274
|
+
} else {
|
|
275
|
+
Write-Output "✗ deployment: VIOLATIONS FOUND (see above)"
|
|
276
|
+
Write-Output " → 宪法 1.1: 部署是该次落地的收尾动作 —— 源与镜像的差异不得跨提交存在。"
|
|
277
|
+
exit 1
|
|
278
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
#
|
|
3
|
+
# Frontmatter-YAML verifier — PowerShell twin of scripts/bash/verify-frontmatter-yaml.sh.
|
|
4
|
+
#
|
|
5
|
+
# Same stdout, same exit code, per FR-008. The rationale lives in the bash side's
|
|
6
|
+
# header; keep the two in step.
|
|
7
|
+
#
|
|
8
|
+
# ⚠️ ONE DECLARED NON-PARITY MECHANISM, stated here rather than discovered later:
|
|
9
|
+
# PowerShell has no YAML parser in the box, and re-implementing one here would be a
|
|
10
|
+
# second, drift-prone implementation of the criterion (形态 3). The Python runtime IS
|
|
11
|
+
# a dependency of this toolchain (the install CLI is a Python package), so — exactly
|
|
12
|
+
# like verify-shapes.ps1 running its bash-carried criteria through `bash -c` — this
|
|
13
|
+
# side runs the SAME python snippet through `python -`. On a host without python this
|
|
14
|
+
# reports the scan as not executable instead of guessing a verdict.
|
|
15
|
+
|
|
16
|
+
$Repo = git rev-parse --show-toplevel 2>$null
|
|
17
|
+
if (-not $Repo -or -not (Test-Path $Repo)) {
|
|
18
|
+
Write-Output "frontmatter-yaml: not inside a git repository"
|
|
19
|
+
exit 1
|
|
20
|
+
}
|
|
21
|
+
$CommandsDir = Join-Path (Resolve-Path $Repo).Path 'commands'
|
|
22
|
+
if (-not (Test-Path $CommandsDir)) {
|
|
23
|
+
Write-Output "frontmatter-yaml: no commands/ directory at $Repo — nothing to scan"
|
|
24
|
+
exit 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
|
28
|
+
if (-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
|
|
29
|
+
if (-not $python) {
|
|
30
|
+
Write-Output "frontmatter-yaml: ⊘ not executable — no python runtime found (PyYAML is the criterion's carrier)"
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$py = @'
|
|
35
|
+
import glob, os, sys, yaml
|
|
36
|
+
d = sys.argv[1]
|
|
37
|
+
parsed = 0
|
|
38
|
+
bad = []
|
|
39
|
+
for f in sorted(glob.glob(os.path.join(d, "*.md"))):
|
|
40
|
+
with open(f, encoding="utf-8") as fh:
|
|
41
|
+
t = fh.read()
|
|
42
|
+
if not t.startswith("---"):
|
|
43
|
+
continue # absent frontmatter is out of scope (ISS-81 ruling)
|
|
44
|
+
parsed += 1
|
|
45
|
+
end = t.find("\n---", 3)
|
|
46
|
+
try:
|
|
47
|
+
yaml.safe_load(t[3:end])
|
|
48
|
+
except Exception as ex:
|
|
49
|
+
print("✗ %s: %s" % (os.path.relpath(f, d), str(ex).splitlines()[0]))
|
|
50
|
+
bad.append(f)
|
|
51
|
+
print("frontmatter-yaml: %d frontmatter block(s) parsed · %d failed" % (parsed, len(bad)))
|
|
52
|
+
sys.exit(1 if bad else 0)
|
|
53
|
+
'@
|
|
54
|
+
|
|
55
|
+
& $python.Source -c $py $CommandsDir
|
|
56
|
+
exit $LASTEXITCODE
|