its-magic 0.1.2-32 → 0.1.2-33

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.
@@ -4,16 +4,16 @@
4
4
  # Part of the its-magic quality chain:
5
5
  # Cursor AI loop → validate-and-push → CI auto-fix (GitHub)
6
6
  #
7
- # Reads TEST_COMMAND (and optionally LINT_COMMAND / TYPECHECK_COMMAND /
8
- # LINT_FIX_COMMAND / FORMAT_COMMAND)
9
- # from docs/engineering/runbook.md, runs them in a loop, and pushes
10
- # only when everything passes.
7
+ # Reads merged scratchpad (installer merge / DEC-0055) for sync policy and
8
+ # runbook keys for commands. Opt-in push only when policy allows; reason codes
9
+ # on stdout/stderr. See docs/engineering/runbook.md (executable wiring: DEC-0058).
11
10
  # -------------------------------------------------------------------
12
11
 
13
12
  Param(
14
13
  [int]$MaxAttempts = 5,
15
14
  [string]$Branch,
16
- [switch]$NoCommit
15
+ [switch]$NoCommit,
16
+ [switch]$DryRun
17
17
  )
18
18
 
19
19
  $ErrorActionPreference = "Stop"
@@ -24,7 +24,38 @@ function Log-Pass { param($Msg) Write-Host "[pass] $Msg" -ForegroundColor Gree
24
24
  function Log-Fail { param($Msg) Write-Host "[fail] $Msg" -ForegroundColor Red }
25
25
  function Log-Warn { param($Msg) Write-Host "[warn] $Msg" -ForegroundColor Yellow }
26
26
 
27
- # --- Read commands from runbook ------------------------------------------
27
+ function Resolve-PythonExe {
28
+ foreach ($name in @("python", "python3")) {
29
+ $cmd = Get-Command $name -ErrorAction SilentlyContinue
30
+ if ($cmd) { return $cmd.Source }
31
+ }
32
+ return $null
33
+ }
34
+
35
+ function Invoke-GateJson {
36
+ param(
37
+ [string]$PythonExe,
38
+ [string]$GateScript,
39
+ [string]$SubCommand,
40
+ [string]$RepoRoot,
41
+ [string]$BranchName
42
+ )
43
+ $outFile = [System.IO.Path]::GetTempFileName()
44
+ $errFile = [System.IO.Path]::GetTempFileName()
45
+ try {
46
+ $p = Start-Process -FilePath $PythonExe `
47
+ -ArgumentList @($GateScript, $SubCommand, "--root", $RepoRoot, "--branch", $BranchName) `
48
+ -Wait -PassThru -NoNewWindow `
49
+ -RedirectStandardOutput $outFile -RedirectStandardError $errFile
50
+ $stderr = if (Test-Path $errFile) { Get-Content -Raw -Path $errFile } else { "" }
51
+ $stdout = if (Test-Path $outFile) { Get-Content -Raw -Path $outFile } else { "" }
52
+ if ($stderr.Trim().Length -gt 0) { Write-Host $stderr.TrimEnd() }
53
+ return @{ ExitCode = $p.ExitCode; StdOut = $stdout.Trim() }
54
+ }
55
+ finally {
56
+ Remove-Item -Path $outFile, $errFile -ErrorAction SilentlyContinue
57
+ }
58
+ }
28
59
 
29
60
  function Read-RunbookKey {
30
61
  param([string]$Key)
@@ -38,16 +69,16 @@ function Read-RunbookKey {
38
69
  return $val
39
70
  }
40
71
 
41
- $TestCmd = Read-RunbookKey "TEST_COMMAND"
42
- $LintCmd = Read-RunbookKey "LINT_COMMAND"
43
- $TypecheckCmd = Read-RunbookKey "TYPECHECK_COMMAND"
44
- $LintFixCmd = Read-RunbookKey "LINT_FIX_COMMAND"
45
- $FormatCmd = Read-RunbookKey "FORMAT_COMMAND"
72
+ $py = Resolve-PythonExe
73
+ if (-not $py) {
74
+ Log-Fail "reason_code=PYTHON_NOT_ON_PATH"
75
+ Log-Warn "Install Python 3 and ensure it is on PATH for merged scratchpad policy gates."
76
+ exit 1
77
+ }
46
78
 
47
- if (-not $TestCmd -and -not $LintCmd) {
48
- Log-Warn "No TEST_COMMAND or LINT_COMMAND found in docs/engineering/runbook.md"
49
- Log-Warn "TEST_COMMAND is required by sync policy for push-eligible paths."
50
- Log-Warn "Fill in the runbook first, then re-run."
79
+ $gateScript = Join-Path $root "scripts\sync_push_gates.py"
80
+ if (-not (Test-Path $gateScript -PathType Leaf)) {
81
+ Log-Fail "reason_code=SYNC_GATE_SCRIPT_MISSING"
51
82
  exit 1
52
83
  }
53
84
 
@@ -58,22 +89,75 @@ if (-not $Branch) {
58
89
 
59
90
  Log-Info "validate-and-push loop"
60
91
  Log-Info "Branch: $Branch | Max attempts: $MaxAttempts"
92
+ if ($DryRun) { Log-Info "Dry-run: no git push will run." }
93
+ Write-Host ""
94
+
95
+ $pol = Invoke-GateJson -PythonExe $py -GateScript $gateScript -SubCommand "policy" -RepoRoot $root -BranchName $Branch
96
+ try {
97
+ $polObj = $pol.StdOut | ConvertFrom-Json
98
+ } catch {
99
+ Log-Fail "reason_code=SYNC_POLICY_PARSE_FAILED"
100
+ exit 1
101
+ }
102
+ if (-not $polObj.ok) {
103
+ Log-Fail ("reason_code=" + $polObj.reason_code)
104
+ exit 1
105
+ }
106
+
107
+ $TestCmd = Read-RunbookKey "TEST_COMMAND"
108
+ $LintCmd = Read-RunbookKey "LINT_COMMAND"
109
+ $TypecheckCmd = Read-RunbookKey "TYPECHECK_COMMAND"
110
+ $LintFixCmd = Read-RunbookKey "LINT_FIX_COMMAND"
111
+ $FormatCmd = Read-RunbookKey "FORMAT_COMMAND"
112
+ $timeoutRaw = Read-RunbookKey "TEST_TIMEOUT_SECONDS"
113
+ $TestTimeoutSec = 120
114
+ if ($timeoutRaw -match '^\d+$') { $TestTimeoutSec = [int]$timeoutRaw }
115
+
116
+ if (-not $TestCmd) {
117
+ Log-Fail "reason_code=TEST_COMMAND_MISSING"
118
+ Log-Warn "TEST_COMMAND is required by sync policy for push-eligible paths."
119
+ exit 1
120
+ }
121
+
61
122
  if ($TestCmd) { Log-Info "TEST_COMMAND: $TestCmd" }
62
123
  if ($LintCmd) { Log-Info "LINT_COMMAND: $LintCmd" }
63
124
  if ($TypecheckCmd) { Log-Info "TYPECHECK_COMMAND: $TypecheckCmd" }
64
125
  if ($LintFixCmd) { Log-Info "LINT_FIX_COMMAND: $LintFixCmd" }
65
126
  if ($FormatCmd) { Log-Info "FORMAT_COMMAND: $FormatCmd" }
127
+ Log-Info "TEST_TIMEOUT_SECONDS: $TestTimeoutSec"
66
128
  Write-Host ""
67
129
 
68
130
  function Run-Cmd {
69
- param([string]$Cmd)
131
+ param([string]$Cmd, [int]$TimeoutSec = 0)
70
132
  $prev = $ErrorActionPreference
71
133
  $ErrorActionPreference = "Continue"
72
134
  try {
73
- Invoke-Expression $Cmd
74
- return $LASTEXITCODE -eq 0
75
- } catch {
76
- return $false
135
+ if ($TimeoutSec -le 0) {
136
+ Invoke-Expression $Cmd
137
+ return @{ Ok = ($LASTEXITCODE -eq 0); TimedOut = $false }
138
+ }
139
+ $job = Start-Job -ScriptBlock {
140
+ param($c, $r)
141
+ Set-Location $r
142
+ try {
143
+ Invoke-Expression $c | Out-Null
144
+ } catch {
145
+ return 1
146
+ }
147
+ if ($null -eq $LASTEXITCODE) { return 0 }
148
+ return $LASTEXITCODE
149
+ } -ArgumentList $Cmd, $root
150
+ $finished = Wait-Job -Job $job -Timeout $TimeoutSec
151
+ if (-not $finished -or $job.State -eq "Running") {
152
+ Stop-Job -Job $job -ErrorAction SilentlyContinue
153
+ Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
154
+ return @{ Ok = $false; TimedOut = $true }
155
+ }
156
+ $code = Receive-Job -Job $job
157
+ Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
158
+ $exitNum = 1
159
+ if ($null -ne $code) { $exitNum = [int]$code[-1] }
160
+ return @{ Ok = ($exitNum -eq 0); TimedOut = $false }
77
161
  } finally {
78
162
  $ErrorActionPreference = $prev
79
163
  }
@@ -86,51 +170,49 @@ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
86
170
  Push-Location $root
87
171
  $allOk = $true
88
172
 
89
- # 1. Run formatter if available
90
173
  if ($FormatCmd) {
91
174
  Log-Info "Running formatter..."
92
- if (Run-Cmd $FormatCmd) {
93
- Log-Pass "Format OK"
94
- } else {
95
- Log-Warn "Formatter reported issues (non-blocking)"
96
- }
175
+ $r = Run-Cmd -Cmd $FormatCmd -TimeoutSec 0
176
+ if ($r.Ok) { Log-Pass "Format OK" } else { Log-Warn "Formatter reported issues (non-blocking)" }
97
177
  }
98
178
 
99
- # 2. Try lint fix if available
100
179
  if ($LintFixCmd) {
101
180
  Log-Info "Running lint auto-fix..."
102
- Run-Cmd $LintFixCmd | Out-Null
181
+ Run-Cmd -Cmd $LintFixCmd -TimeoutSec 0 | Out-Null
103
182
  }
104
183
 
105
- # 3. Run lint check
106
184
  if ($LintCmd) {
107
185
  Log-Info "Running lint check..."
108
- if (Run-Cmd $LintCmd) {
109
- Log-Pass "Lint OK"
110
- } else {
111
- Log-Fail "Lint failed"
186
+ $r = Run-Cmd -Cmd $LintCmd -TimeoutSec $TestTimeoutSec
187
+ if ($r.TimedOut) {
188
+ Log-Fail "reason_code=OPTIONAL_CHECK_FAILED"
189
+ $allOk = $false
190
+ } elseif ($r.Ok) { Log-Pass "Lint OK" } else {
191
+ Log-Fail "reason_code=OPTIONAL_CHECK_FAILED"
112
192
  $allOk = $false
113
193
  }
114
194
  }
115
195
 
116
- # 4. Run typecheck (optional)
117
196
  if ($TypecheckCmd) {
118
197
  Log-Info "Running typecheck..."
119
- if (Run-Cmd $TypecheckCmd) {
120
- Log-Pass "Typecheck OK"
121
- } else {
122
- Log-Fail "Typecheck failed"
198
+ $r = Run-Cmd -Cmd $TypecheckCmd -TimeoutSec $TestTimeoutSec
199
+ if ($r.TimedOut) {
200
+ Log-Fail "reason_code=OPTIONAL_CHECK_FAILED"
201
+ $allOk = $false
202
+ } elseif ($r.Ok) { Log-Pass "Typecheck OK" } else {
203
+ Log-Fail "reason_code=OPTIONAL_CHECK_FAILED"
123
204
  $allOk = $false
124
205
  }
125
206
  }
126
207
 
127
- # 5. Run tests
128
208
  if ($TestCmd) {
129
209
  Log-Info "Running tests..."
130
- if (Run-Cmd $TestCmd) {
131
- Log-Pass "Tests OK"
132
- } else {
133
- Log-Fail "Tests failed"
210
+ $r = Run-Cmd -Cmd $TestCmd -TimeoutSec $TestTimeoutSec
211
+ if ($r.TimedOut) {
212
+ Log-Fail "reason_code=TEST_TIMEOUT"
213
+ $allOk = $false
214
+ } elseif ($r.Ok) { Log-Pass "Tests OK" } else {
215
+ Log-Fail "reason_code=TEST_FAILED"
134
216
  $allOk = $false
135
217
  }
136
218
  }
@@ -158,23 +240,41 @@ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
158
240
 
159
241
  if (-not $passed) { exit 1 }
160
242
 
243
+ $post = Invoke-GateJson -PythonExe $py -GateScript $gateScript -SubCommand "post" -RepoRoot $root -BranchName $Branch
244
+ try {
245
+ $postObj = $post.StdOut | ConvertFrom-Json
246
+ } catch {
247
+ Log-Fail "reason_code=SYNC_POST_PARSE_FAILED"
248
+ exit 1
249
+ }
250
+ if (-not $postObj.ok) {
251
+ Log-Fail ("reason_code=" + $postObj.reason_code)
252
+ exit 1
253
+ }
254
+
161
255
  Write-Host ""
162
256
 
163
- if (-not $NoCommit) {
164
- Push-Location $root
165
- $status = git status --porcelain
166
- if ($status) {
167
- Log-Info "Staging and committing changes..."
168
- git add -A
169
- git commit -m "fix: address check failures (validate-and-push)"
257
+ if ($DryRun -or $NoCommit) {
258
+ if ($DryRun) {
259
+ Log-Pass "reason_code=SYNC_PUSHED (dry-run; no git push)"
170
260
  } else {
171
- Log-Info "Working tree clean, nothing to commit."
261
+ Log-Info "Auto-commit disabled (--NoCommit). Push manually when ready."
262
+ Log-Pass "reason_code=SYNC_PUSHED (checks only; push skipped by flag)"
172
263
  }
264
+ exit 0
265
+ }
173
266
 
174
- Log-Info "Pushing to origin/$Branch..."
175
- git push origin $Branch
176
- Log-Pass "Push successful."
177
- Pop-Location
267
+ Push-Location $root
268
+ $status = git status --porcelain
269
+ if ($status) {
270
+ Log-Info "Staging and committing changes..."
271
+ git add -A
272
+ git commit -m "fix: address check failures (validate-and-push)"
178
273
  } else {
179
- Log-Info "Auto-commit disabled (--NoCommit). Push manually when ready."
274
+ Log-Info "Working tree clean, nothing to commit."
180
275
  }
276
+
277
+ Log-Info "Pushing to origin/$Branch..."
278
+ git push origin $Branch
279
+ Log-Pass "reason_code=SYNC_PUSHED"
280
+ Pop-Location
@@ -1,18 +1,25 @@
1
- #!/usr/bin/env sh
1
+ #!/usr/bin/env bash
2
2
  # -------------------------------------------------------------------
3
3
  # validate-and-push.sh — local test-fix-push loop
4
4
  #
5
5
  # Part of the its-magic quality chain:
6
6
  # Cursor AI loop → validate-and-push → CI auto-fix (GitHub)
7
7
  #
8
- # Reads TEST_COMMAND (and optionally LINT_COMMAND / TYPECHECK_COMMAND /
9
- # LINT_FIX_COMMAND / FORMAT_COMMAND)
10
- # from docs/engineering/runbook.md, runs them in a loop, and pushes
11
- # only when everything passes.
8
+ # Bash is required for safe eval + optional timeout wrapping (DEC-0058 / parity
9
+ # with validate-and-push.ps1).
10
+ #
11
+ # Reads merged scratchpad (installer merge) for sync policy and runbook keys
12
+ # for commands. Opt-in push only when policy allows. See runbook (DEC-0058).
12
13
  # -------------------------------------------------------------------
13
- set -e
14
+ set -euo pipefail
14
15
 
15
16
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
17
+ DRY_RUN=0
18
+ if [ "${1:-}" = "--dry-run" ]; then
19
+ DRY_RUN=1
20
+ shift
21
+ fi
22
+
16
23
  MAX_ATTEMPTS="${1:-5}"
17
24
  BRANCH="${2:-}"
18
25
  AUTO_COMMIT="${3:-true}"
@@ -28,13 +35,54 @@ log_pass() { printf "${pass_color}[pass]${reset} %s\n" "$*"; }
28
35
  log_fail() { printf "${fail_color}[fail]${reset} %s\n" "$*"; }
29
36
  log_warn() { printf "${warn_color}[warn]${reset} %s\n" "$*"; }
30
37
 
31
- # --- Read commands from runbook ------------------------------------------
38
+ resolve_python() {
39
+ if command -v python >/dev/null 2>&1; then printf "%s" "python"; return; fi
40
+ if command -v python3 >/dev/null 2>&1; then printf "%s" "python3"; return; fi
41
+ printf "%s" ""
42
+ }
43
+
44
+ run_with_timeout() {
45
+ local sec="$1"
46
+ shift
47
+ if command -v timeout >/dev/null 2>&1; then
48
+ timeout "$sec" "$@"
49
+ elif command -v gtimeout >/dev/null 2>&1; then
50
+ gtimeout "$sec" "$@"
51
+ else
52
+ "$@"
53
+ fi
54
+ }
55
+
56
+ # Run runbook command string from repo root; optional wall-clock cap.
57
+ run_runbook_cmd() {
58
+ local cmd="$1"
59
+ local kind="$2"
60
+ local sec="$3"
61
+ if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then
62
+ if ! run_with_timeout "$sec" bash -c 'cd "$1" && eval "$2"' _ "$ROOT" "$cmd"; then
63
+ local ec=$?
64
+ if [ "$kind" = "test" ]; then
65
+ if [ "$ec" = "124" ] || [ "$ec" = "143" ]; then log_fail "reason_code=TEST_TIMEOUT"; else log_fail "reason_code=TEST_FAILED"; fi
66
+ else
67
+ log_fail "reason_code=OPTIONAL_CHECK_FAILED"
68
+ fi
69
+ return 1
70
+ fi
71
+ else
72
+ if ! ( cd "$ROOT" && eval "$cmd" ); then
73
+ if [ "$kind" = "test" ]; then log_fail "reason_code=TEST_FAILED"; else log_fail "reason_code=OPTIONAL_CHECK_FAILED"; fi
74
+ return 1
75
+ fi
76
+ fi
77
+ return 0
78
+ }
32
79
 
33
80
  RUNBOOK="$ROOT/docs/engineering/runbook.md"
34
81
 
35
82
  read_runbook_key() {
36
- key="$1"
83
+ local key="$1"
37
84
  if [ ! -f "$RUNBOOK" ]; then return; fi
85
+ local value
38
86
  value=$(sed -n "s/^${key}:[[:space:]]*\(.\{1,\}\)$/\1/p" "$RUNBOOK" | head -n 1)
39
87
  case "$value" in
40
88
  ""|"..."|"<...>"|"TODO") return ;;
@@ -42,16 +90,40 @@ read_runbook_key() {
42
90
  printf "%s" "$value"
43
91
  }
44
92
 
45
- TEST_CMD=$(read_runbook_key "TEST_COMMAND")
46
- LINT_CMD=$(read_runbook_key "LINT_COMMAND")
47
- TYPECHECK_CMD=$(read_runbook_key "TYPECHECK_COMMAND")
48
- LINT_FIX_CMD=$(read_runbook_key "LINT_FIX_COMMAND")
49
- FORMAT_CMD=$(read_runbook_key "FORMAT_COMMAND")
93
+ run_sync_gate() {
94
+ local sub="$1"
95
+ local out err line gate_ok gate_rc
96
+ out="$(mktemp)"
97
+ err="$(mktemp)"
98
+ set +e
99
+ "$PY" "$GATE_SCRIPT" "$sub" --root "$ROOT" --branch "$BRANCH" >"$out" 2>"$err"
100
+ set -e
101
+ if [ -s "$err" ]; then cat "$err" >&2; fi
102
+ line="$(grep '^{' "$out" | tail -n 1)"
103
+ rm -f "$out" "$err"
104
+ if [ -z "$line" ]; then
105
+ log_fail "reason_code=SYNC_GATE_EMPTY_RESPONSE"
106
+ return 1
107
+ fi
108
+ gate_ok="$("$PY" -c "import json,sys; print(1 if json.loads(sys.argv[1]).get('ok') else 0)" "$line" 2>/dev/null || echo 0)"
109
+ gate_rc="$("$PY" -c "import json,sys; print(json.loads(sys.argv[1]).get('reason_code') or '')" "$line" 2>/dev/null || true)"
110
+ if [ "$gate_ok" != "1" ]; then
111
+ log_fail "reason_code=${gate_rc:-SYNC_GATE_BLOCK}"
112
+ return 1
113
+ fi
114
+ return 0
115
+ }
50
116
 
51
- if [ -z "$TEST_CMD" ] && [ -z "$LINT_CMD" ]; then
52
- log_warn "No TEST_COMMAND or LINT_COMMAND found in docs/engineering/runbook.md"
53
- log_warn "TEST_COMMAND is required by sync policy for push-eligible paths."
54
- log_warn "Fill in the runbook first, then re-run."
117
+ PY="$(resolve_python)"
118
+ if [ -z "$PY" ]; then
119
+ log_fail "reason_code=PYTHON_NOT_ON_PATH"
120
+ log_warn "Install Python 3 and ensure it is on PATH for merged scratchpad policy gates."
121
+ exit 1
122
+ fi
123
+
124
+ GATE_SCRIPT="$ROOT/scripts/sync_push_gates.py"
125
+ if [ ! -f "$GATE_SCRIPT" ]; then
126
+ log_fail "reason_code=SYNC_GATE_SCRIPT_MISSING"
55
127
  exit 1
56
128
  fi
57
129
 
@@ -61,11 +133,35 @@ fi
61
133
 
62
134
  log_info "validate-and-push loop"
63
135
  log_info "Branch: $BRANCH | Max attempts: $MAX_ATTEMPTS"
64
- [ -n "$TEST_CMD" ] && log_info "TEST_COMMAND: $TEST_CMD"
65
- [ -n "$LINT_CMD" ] && log_info "LINT_COMMAND: $LINT_CMD"
136
+ if [ "$DRY_RUN" = "1" ]; then log_info "Dry-run: no git push will run."; fi
137
+ printf "\n"
138
+
139
+ if ! run_sync_gate policy; then exit 1; fi
140
+
141
+ TEST_CMD=$(read_runbook_key "TEST_COMMAND")
142
+ LINT_CMD=$(read_runbook_key "LINT_COMMAND")
143
+ TYPECHECK_CMD=$(read_runbook_key "TYPECHECK_COMMAND")
144
+ LINT_FIX_CMD=$(read_runbook_key "LINT_FIX_COMMAND")
145
+ FORMAT_CMD=$(read_runbook_key "FORMAT_COMMAND")
146
+ TIMEOUT_RAW=$(read_runbook_key "TEST_TIMEOUT_SECONDS")
147
+ TEST_TIMEOUT_SEC=120
148
+ case "$TIMEOUT_RAW" in
149
+ ''|*[!0-9]*) ;;
150
+ *) TEST_TIMEOUT_SEC="$TIMEOUT_RAW" ;;
151
+ esac
152
+
153
+ if [ -z "$TEST_CMD" ]; then
154
+ log_fail "reason_code=TEST_COMMAND_MISSING"
155
+ log_warn "TEST_COMMAND is required by sync policy for push-eligible paths."
156
+ exit 1
157
+ fi
158
+
159
+ [ -n "$TEST_CMD" ] && log_info "TEST_COMMAND: $TEST_CMD"
160
+ [ -n "$LINT_CMD" ] && log_info "LINT_COMMAND: $LINT_CMD"
66
161
  [ -n "$TYPECHECK_CMD" ] && log_info "TYPECHECK_COMMAND: $TYPECHECK_CMD"
67
162
  [ -n "$LINT_FIX_CMD" ] && log_info "LINT_FIX_COMMAND: $LINT_FIX_CMD"
68
- [ -n "$FORMAT_CMD" ] && log_info "FORMAT_COMMAND: $FORMAT_CMD"
163
+ [ -n "$FORMAT_CMD" ] && log_info "FORMAT_COMMAND: $FORMAT_CMD"
164
+ log_info "TEST_TIMEOUT_SECONDS: $TEST_TIMEOUT_SEC"
69
165
  printf "\n"
70
166
 
71
167
  attempt=0
@@ -76,53 +172,29 @@ while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
76
172
  cd "$ROOT"
77
173
  all_ok=true
78
174
 
79
- # 1. Run formatter if available
80
175
  if [ -n "$FORMAT_CMD" ]; then
81
176
  log_info "Running formatter..."
82
- if eval "$FORMAT_CMD"; then
83
- log_pass "Format OK"
84
- else
85
- log_warn "Formatter reported issues (non-blocking)"
86
- fi
177
+ if eval "$FORMAT_CMD"; then log_pass "Format OK"; else log_warn "Formatter reported issues (non-blocking)"; fi
87
178
  fi
88
179
 
89
- # 2. Try lint fix if available
90
180
  if [ -n "$LINT_FIX_CMD" ]; then
91
181
  log_info "Running lint auto-fix..."
92
182
  eval "$LINT_FIX_CMD" || true
93
183
  fi
94
184
 
95
- # 3. Run lint check
96
185
  if [ -n "$LINT_CMD" ]; then
97
186
  log_info "Running lint check..."
98
- if eval "$LINT_CMD"; then
99
- log_pass "Lint OK"
100
- else
101
- log_fail "Lint failed"
102
- all_ok=false
103
- fi
187
+ if run_runbook_cmd "$LINT_CMD" "optional" "$TEST_TIMEOUT_SEC"; then log_pass "Lint OK"; else all_ok=false; fi
104
188
  fi
105
189
 
106
- # 4. Run typecheck (optional)
107
190
  if [ -n "$TYPECHECK_CMD" ]; then
108
191
  log_info "Running typecheck..."
109
- if eval "$TYPECHECK_CMD"; then
110
- log_pass "Typecheck OK"
111
- else
112
- log_fail "Typecheck failed"
113
- all_ok=false
114
- fi
192
+ if run_runbook_cmd "$TYPECHECK_CMD" "optional" "$TEST_TIMEOUT_SEC"; then log_pass "Typecheck OK"; else all_ok=false; fi
115
193
  fi
116
194
 
117
- # 5. Run tests
118
195
  if [ -n "$TEST_CMD" ]; then
119
196
  log_info "Running tests..."
120
- if eval "$TEST_CMD"; then
121
- log_pass "Tests OK"
122
- else
123
- log_fail "Tests failed"
124
- all_ok=false
125
- fi
197
+ if run_runbook_cmd "$TEST_CMD" "test" "$TEST_TIMEOUT_SEC"; then log_pass "Tests OK"; else all_ok=false; fi
126
198
  fi
127
199
 
128
200
  if [ "$all_ok" = "true" ]; then
@@ -134,7 +206,7 @@ while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
134
206
  log_fail "Reached max attempts ($MAX_ATTEMPTS). Aborting push."
135
207
  printf "\n"
136
208
  log_warn "Fix the issues above, then re-run:"
137
- log_warn " sh scripts/validate-and-push.sh"
209
+ log_warn " bash scripts/validate-and-push.sh"
138
210
  exit 1
139
211
  fi
140
212
 
@@ -143,21 +215,29 @@ while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
143
215
  read -r _unused
144
216
  done
145
217
 
218
+ if ! run_sync_gate post; then exit 1; fi
219
+
146
220
  printf "\n"
147
221
 
148
- if [ "$AUTO_COMMIT" = "true" ]; then
149
- cd "$ROOT"
150
- if [ -n "$(git status --porcelain)" ]; then
151
- log_info "Staging and committing changes..."
152
- git add -A
153
- git commit -m "fix: address check failures (validate-and-push)"
222
+ if [ "$DRY_RUN" = "1" ] || [ "$AUTO_COMMIT" != "true" ]; then
223
+ if [ "$DRY_RUN" = "1" ]; then
224
+ log_pass "reason_code=SYNC_PUSHED (dry-run; no git push)"
154
225
  else
155
- log_info "Working tree clean, nothing to commit."
226
+ log_info "Auto-commit disabled. Push manually when ready."
227
+ log_pass "reason_code=SYNC_PUSHED (checks only; push skipped by flag)"
156
228
  fi
229
+ exit 0
230
+ fi
157
231
 
158
- log_info "Pushing to origin/$BRANCH..."
159
- git push origin "$BRANCH"
160
- log_pass "Push successful."
232
+ cd "$ROOT"
233
+ if [ -n "$(git status --porcelain)" ]; then
234
+ log_info "Staging and committing changes..."
235
+ git add -A
236
+ git commit -m "fix: address check failures (validate-and-push)"
161
237
  else
162
- log_info "Auto-commit disabled. Push manually when ready."
238
+ log_info "Working tree clean, nothing to commit."
163
239
  fi
240
+
241
+ log_info "Pushing to origin/$BRANCH..."
242
+ git push origin "$BRANCH"
243
+ log_pass "reason_code=SYNC_PUSHED"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validate documentation profile surfaces (DEC-0059).
4
+
5
+ Reads merged scratchpad via installer.merge_scratchpad_layers (DEC-0058 pattern).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+
14
+ # Repo root (parent of scripts/)
15
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ _REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
17
+
18
+ if _REPO_ROOT not in sys.path:
19
+ sys.path.insert(0, _REPO_ROOT)
20
+ if _SCRIPT_DIR not in sys.path:
21
+ sys.path.insert(0, _SCRIPT_DIR)
22
+
23
+ import installer # noqa: E402
24
+ import doc_profile_lib # noqa: E402
25
+
26
+
27
+ def _fail(messages: list[str]) -> int:
28
+ for m in messages:
29
+ print(m, file=sys.stderr)
30
+ return 1
31
+
32
+
33
+ def main() -> int:
34
+ parser = argparse.ArgumentParser(description="Validate README/doc profile vs merged scratchpad.")
35
+ parser.add_argument(
36
+ "--repo",
37
+ default=_REPO_ROOT,
38
+ help="Target repository root (default: parent of scripts/).",
39
+ )
40
+ parser.add_argument(
41
+ "--no-template-parity",
42
+ action="store_true",
43
+ help="Skip active vs template/ parity (for fixture-only trees).",
44
+ )
45
+ parser.add_argument(
46
+ "--self-test",
47
+ action="store_true",
48
+ help="Run resolver matrix self-test and exit.",
49
+ )
50
+ args = parser.parse_args()
51
+
52
+ if args.self_test:
53
+ doc_profile_lib.self_test_resolver()
54
+ print("[DOC_PROFILE_SELF_TEST_OK]")
55
+ return 0
56
+
57
+ target = os.path.abspath(args.repo)
58
+ template_root = None if args.no_template_parity else os.path.join(target, "template")
59
+
60
+ merged, paths = installer.merge_scratchpad_layers(target)
61
+ merge_errors: list[str] = []
62
+ if not os.path.isfile(paths["example"]):
63
+ merge_errors.append(
64
+ "[DOC_PROFILE_MERGE_ERROR] EXAMPLE_LAYER_MISSING: "
65
+ f".cursor/scratchpad.local.example.md not found under {target}."
66
+ )
67
+ if not os.path.isfile(paths["baseline"]):
68
+ merge_errors.append(
69
+ "[DOC_PROFILE_MERGE_ERROR] MATERIALIZED_BASELINE_MISSING: "
70
+ f".cursor/scratchpad.md not found under {target}."
71
+ )
72
+ if merge_errors:
73
+ return _fail(merge_errors)
74
+
75
+ ok, scratch_diagnostics = installer.validate_merged_scratchpad(target)
76
+ if not ok:
77
+ out = [ln for ln in scratch_diagnostics if "SCRATCHPAD_MERGE_ERROR" in ln or "REQUIRED_KEY" in ln]
78
+ mapped = [
79
+ ln.replace("[SCRATCHPAD_MERGE_ERROR]", "[DOC_PROFILE_MERGE_ERROR]", 1)
80
+ if ln.startswith("[SCRATCHPAD_MERGE_ERROR]")
81
+ else f"[DOC_PROFILE_MERGE_ERROR] {ln}"
82
+ for ln in (out or scratch_diagnostics)
83
+ ]
84
+ return _fail(mapped)
85
+
86
+ readme_path = os.path.join(target, "README.md")
87
+ readme_txt = ""
88
+ if os.path.isfile(readme_path):
89
+ with open(readme_path, "r", encoding="utf-8") as f:
90
+ readme_txt = f.read()
91
+ for w in doc_profile_lib.optional_mode_warnings(merged, readme_txt):
92
+ print(w, file=sys.stderr)
93
+
94
+ errs = doc_profile_lib.validate_repo_doc_profile(target, merged, template_root)
95
+ if errs:
96
+ return _fail(errs)
97
+
98
+ print("[DOC_PROFILE_VALIDATE_OK]")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())