From 7ec5da59fada1d6806c984d5dd080740c2650343 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Mon, 24 Aug 2026 17:14:33 +0800 Subject: [PATCH 1/3] fix(cli): align CLI severity glyphs with the shared vocabulary Migrate every raw severity glyph in pkg/cmd to the pkg/glyph constants and correct the sites whose glyph misled: - The plan-time unsafe-changes heading carried Refused before anything was refused; it now carries Attention, matching the PR plan comment and list-plans. - The apply-blocked heading keeps Refused but names the refusal itself: "Apply blocked: N unsafe change(s) detected". - The multi-deployment first-failure callout marked an actual failure with a bare one-cell warning glyph; it now carries Failed, matching the deployment row beside it. - The database-locked conflict is a refusal, not a failure, so its heading moves from Failed to Refused. Co-Authored-By: Claude Fable 5 --- TEMPLATES.md | 8 ++++---- docs/lint-and-safety-levels.md | 4 ++-- e2e/local/local_test.go | 8 +++++--- e2e/local/vitess_test.go | 9 ++++++--- integration/cli_test.go | 3 ++- pkg/cmd/commands/fixlint.go | 3 ++- pkg/cmd/commands/plan.go | 4 ++-- pkg/cmd/commands/watch_tui_test.go | 4 ++-- pkg/cmd/commands/watch_tui_view_multi.go | 5 +++-- pkg/cmd/internal/templates/locks.go | 7 ++++--- pkg/cmd/internal/templates/plan.go | 17 +++++++++++------ pkg/cmd/internal/templates/plan_unsafe_test.go | 15 +++++++++++++++ pkg/cmd/internal/templates/progress.go | 9 +++++---- pkg/cmd/internal/templates/progress_multi.go | 5 +++-- 14 files changed, 66 insertions(+), 35 deletions(-) diff --git a/TEMPLATES.md b/TEMPLATES.md index 973c6b0a2..8cb814d5c 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -2021,7 +2021,7 @@ schemabot apply -e staging ``` -❌ Apply Blocked: Database Locked +⛔ Apply blocked: database locked ┌───────────────────────────────────┐ │ Database: testapp (mysql) │ @@ -2047,7 +2047,7 @@ Options: ``` -❌ Apply Blocked: Database Locked +⛔ Apply blocked: database locked ┌────────────────────────────────────────────────┐ │ Database: testapp (mysql) │ @@ -7708,7 +7708,7 @@ schemabot apply -e production │ Deployments: 1 completed · 1 halted · 1 failed │ └───────────────────────────────────────────────────────┘ - ⚠ First failure: eu-west — duplicate key name 'idx_orders_source' + ❌ First failure: eu-west — duplicate key name 'idx_orders_source' Next: review failure in eu-west @@ -8243,7 +8243,7 @@ Lint violations: Non-blocking warnings during plan/apply Unsafe blocked: Destructive changes require --allow-unsafe -⛔ Unsafe Changes Detected: +⛔ Apply blocked: 3 unsafe change(s) detected • users: DROP COLUMN email • orders: DROP TABLE • products: diff --git a/docs/lint-and-safety-levels.md b/docs/lint-and-safety-levels.md index d86ffc59d..bff15c3fc 100644 --- a/docs/lint-and-safety-levels.md +++ b/docs/lint-and-safety-levels.md @@ -158,8 +158,8 @@ rejected up front while they are present. | Icon | Where it appears | Meaning | |---|---|---| -| ⛔ | Plan comment (**Cannot apply**), apply-rejection comments (**Unsafe Changes Detected**, **Apply rejected**, **Apply Blocked: PR Is Merged/Closed**), CLI unsafe-changes list | Refusal: this will not or did not proceed | -| ⚠️ | Plan comment (**Issues**) | Caution: unsafe changes to review before applying | +| ⛔ | Plan comment (**Cannot apply**), apply-rejection comments (**Unsafe Changes Detected**, **Apply rejected**, **Apply Blocked: PR Is Merged/Closed**), CLI apply-blocked headings (**Apply blocked**) | Refusal: this will not or did not proceed | +| ⚠️ | Plan comment (**Issues**), CLI plan output (**Unsafe Changes Detected**) | Caution: unsafe changes to review before applying | | 🚨 | Apply-rejection comment; CLI apply output | The `--allow-unsafe` instruction, or (CLI) the banner confirming it was supplied | | ⚙️ | Plan and locked apply comments (**Direct execution**) | Consent disclosure for native-DDL statements | | 💡 | Plan comment and CLI (**Lint Warnings**) | Advisory best-practice findings | diff --git a/e2e/local/local_test.go b/e2e/local/local_test.go index 18c4375c7..51338266d 100644 --- a/e2e/local/local_test.go +++ b/e2e/local/local_test.go @@ -330,7 +330,7 @@ CREATE TABLE %s ( out := e2eutil.RunCLIInDir(t, binPath, schemaDir, "plan", "-e", "staging", "--endpoint", endpoint) e2eutil.AssertContains(t, out, "DROP COLUMN") e2eutil.AssertContains(t, out, "legacy_field") - // Unsafe changes should be shown with ⛔ (not ⚠️ lint warning) + // Unsafe changes at plan time await consent, shown with ⚠️ e2eutil.AssertContains(t, out, "Unsafe Changes Detected") } @@ -575,7 +575,8 @@ CREATE TABLE %s ( require.Error(t, err, "expected apply to fail without --allow-unsafe for DROP INDEX") // Verify the output contains expected messages - e2eutil.AssertContains(t, out, "Unsafe Changes Detected") + e2eutil.AssertContains(t, out, "Apply blocked") + e2eutil.AssertContains(t, out, "unsafe change(s) detected") e2eutil.AssertContains(t, out, "DROP INDEX") e2eutil.AssertContains(t, out, "--allow-unsafe") // Should also show the plan @@ -680,7 +681,8 @@ CREATE TABLE %s ( require.Error(t, err, "expected apply to fail without --allow-unsafe for DROP TABLE") // Verify the output contains expected messages - e2eutil.AssertContains(t, out, "Unsafe Changes Detected") + e2eutil.AssertContains(t, out, "Apply blocked") + e2eutil.AssertContains(t, out, "unsafe change(s) detected") e2eutil.AssertContains(t, out, "DROP TABLE") e2eutil.AssertContains(t, out, "--allow-unsafe") } diff --git a/e2e/local/vitess_test.go b/e2e/local/vitess_test.go index a17a6b557..552c89fc7 100644 --- a/e2e/local/vitess_test.go +++ b/e2e/local/vitess_test.go @@ -1157,7 +1157,8 @@ func TestVitess_Apply_DropIndex_BlockedWithoutFlag(t *testing.T) { "-s", ".", "-e", "staging", "--endpoint", endpoint, "-y", "-o", "log") t.Logf("DROP INDEX apply output:\n%s", out) require.Error(t, err, "expected apply to fail without --allow-unsafe") - assert.Contains(t, out, "Unsafe Changes Detected") + assert.Contains(t, out, "Apply blocked") + assert.Contains(t, out, "unsafe change(s) detected") } // TestVitess_Apply_VSchemaVindexRemoval_BlockedWithoutFlag exercises the @@ -1199,7 +1200,8 @@ func TestVitess_Apply_VSchemaVindexRemoval_BlockedWithoutFlag(t *testing.T) { "-s", ".", "-e", "staging", "--endpoint", endpoint, "-y", "-o", "log") t.Logf("VSchema vindex removal apply output:\n%s", out) require.Error(t, err, "expected apply to fail without --allow-unsafe") - assert.Contains(t, out, "Unsafe Changes Detected") + assert.Contains(t, out, "Apply blocked") + assert.Contains(t, out, "unsafe change(s) detected") // Acknowledging the removal with --allow-unsafe lets the apply proceed. clearSchemaBotState(t) @@ -1250,7 +1252,8 @@ func TestVitess_Apply_VSchemaVindexTypeChange_BlockedWithoutFlag(t *testing.T) { "-s", ".", "-e", "staging", "--endpoint", endpoint, "-y", "-o", "log") t.Logf("VSchema vindex type-change apply output:\n%s", out) require.Error(t, err, "expected apply to fail without --allow-unsafe") - assert.Contains(t, out, "Unsafe Changes Detected") + assert.Contains(t, out, "Apply blocked") + assert.Contains(t, out, "unsafe change(s) detected") // Acknowledging the mutation with --allow-unsafe lets the apply proceed. clearSchemaBotState(t) diff --git a/integration/cli_test.go b/integration/cli_test.go index ed5cbc2ee..32215f2a1 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -428,7 +428,8 @@ CREATE TABLE accounts ( // Should exit with error require.Error(t, err, "expected apply to fail without --allow-unsafe") // Should show unsafe changes message - assertContains(t, out, "Unsafe Changes Detected") + assertContains(t, out, "Apply blocked") + assertContains(t, out, "unsafe change(s) detected") assertContains(t, out, "--allow-unsafe") }) diff --git a/pkg/cmd/commands/fixlint.go b/pkg/cmd/commands/fixlint.go index cd40ef11a..dc4f44bd1 100644 --- a/pkg/cmd/commands/fixlint.go +++ b/pkg/cmd/commands/fixlint.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/lint" ) @@ -70,7 +71,7 @@ func (cmd *FixLintCmd) Run(g *Globals) error { // Show unfixable issues if len(result.UnfixableIssues) > 0 { - fmt.Printf("❌ %d issue(s) require manual fix:\n", len(result.UnfixableIssues)) + fmt.Printf(glyph.Failed+" %d issue(s) require manual fix:\n", len(result.UnfixableIssues)) for _, issue := range result.UnfixableIssues { loc := issue.Table if issue.Column != "" { diff --git a/pkg/cmd/commands/plan.go b/pkg/cmd/commands/plan.go index ee96b20ad..7e68fa366 100644 --- a/pkg/cmd/commands/plan.go +++ b/pkg/cmd/commands/plan.go @@ -216,7 +216,7 @@ func writeEnvPlan(result *apitypes.PlanResponse) { // writePlanBody writes the plan body (errors, changes, unsafe warnings, lint, summary). // Used by both writeEnvPlan (plan command) and OutputPlanResult (apply command). -// When isApply is true, the ⛔ unsafe warning is skipped (apply shows its own 🚨 warning). +// When isApply is true, the ⚠️ unsafe warning is skipped (apply shows its own 🚨 warning). func writePlanBody(result *apitypes.PlanResponse, isApply bool) { // Check for errors if len(result.Errors) > 0 { @@ -295,7 +295,7 @@ func writePlanBody(result *apitypes.PlanResponse, isApply bool) { templates.WriteNamespaceChanges(nsChanges, !isVitess, result.Database) } - // Check for unsafe changes and show with ⛔ (error level) + // Check for unsafe changes and show with ⚠️ (attention — the changes await consent) // Skip in apply context — apply shows its own 🚨 warning via WriteUnsafeWarningAllowed unsafeChanges := result.UnsafeChanges() if len(unsafeChanges) > 0 && !isApply { diff --git a/pkg/cmd/commands/watch_tui_test.go b/pkg/cmd/commands/watch_tui_test.go index c20de2d46..879379ca6 100644 --- a/pkg/cmd/commands/watch_tui_test.go +++ b/pkg/cmd/commands/watch_tui_test.go @@ -284,7 +284,7 @@ func TestWatchModel_MultiDeploymentView(t *testing.T) { view := m.View() assert.Contains(t, view, "1 completed · 1 halted · 1 failed") - assert.Contains(t, view, "⚠ First failure: eu-west — duplicate key name 'idx_orders_source'") + assert.Contains(t, view, "❌ First failure: eu-west — duplicate key name 'idx_orders_source'") assert.Contains(t, view, "Apply ID: apply-multi-test") assert.Contains(t, view, "Environment: production") assertContainsInOrder(t, view, @@ -318,7 +318,7 @@ func TestWatchModel_MultiDeploymentViewRunningDegraded(t *testing.T) { assert.Contains(t, view, "running (degraded)") assert.Contains(t, view, "1 running · 1 failed") - assert.Contains(t, view, "⚠ First failure: eu — duplicate column") + assert.Contains(t, view, "❌ First failure: eu — duplicate column") assert.Contains(t, view, "🔄 us — running table copy") } diff --git a/pkg/cmd/commands/watch_tui_view_multi.go b/pkg/cmd/commands/watch_tui_view_multi.go index 0bbd62ccc..faff12430 100644 --- a/pkg/cmd/commands/watch_tui_view_multi.go +++ b/pkg/cmd/commands/watch_tui_view_multi.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/block/schemabot/pkg/cmd/internal/templates" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/presentation" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/storage" @@ -59,9 +60,9 @@ func (m WatchModel) writeMultiDeploymentHeader(b *strings.Builder, model present if model.FirstFailure != nil { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) if model.FirstFailure.Error != "" { - fmt.Fprintf(b, "%s\n", errStyle.Render(fmt.Sprintf("⚠ First failure: %s — %s", model.FirstFailure.Deployment, model.FirstFailure.Error))) + fmt.Fprintf(b, "%s\n", errStyle.Render(fmt.Sprintf(glyph.Failed+" First failure: %s — %s", model.FirstFailure.Deployment, model.FirstFailure.Error))) } else { - fmt.Fprintf(b, "%s\n", errStyle.Render(fmt.Sprintf("⚠ First failure: %s", model.FirstFailure.Deployment))) + fmt.Fprintf(b, "%s\n", errStyle.Render(fmt.Sprintf(glyph.Failed+" First failure: %s", model.FirstFailure.Deployment))) } } if m.applyID != "" { diff --git a/pkg/cmd/internal/templates/locks.go b/pkg/cmd/internal/templates/locks.go index f2c15b994..59aea6860 100644 --- a/pkg/cmd/internal/templates/locks.go +++ b/pkg/cmd/internal/templates/locks.go @@ -5,6 +5,7 @@ import ( "time" "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/ui" ) @@ -31,7 +32,7 @@ func WriteLockReleased(database, dbType string) { // WriteLockForceReleased writes the force release message. func WriteLockForceReleased(database, dbType, previousOwner string) { - fmt.Printf("⚠️ Force released lock for %s (%s)\n", database, dbType) + fmt.Printf(glyph.Attention+" Force released lock for %s (%s)\n", database, dbType) fmt.Printf(" Previous owner: %s\n", previousOwner) } @@ -48,7 +49,7 @@ type LockConflictData struct { // WriteLockConflict writes the lock conflict error message. func WriteLockConflict(data LockConflictData) { fmt.Println() - fmt.Println("❌ Apply Blocked: Database Locked") + fmt.Println(glyph.Refused + " Apply blocked: database locked") fmt.Println() // Show a table of lock info @@ -124,7 +125,7 @@ func WriteLockTypeScanFailed(err error) { // WriteUnlockNotOwned writes the message when trying to unlock without ownership. func WriteUnlockNotOwned(database, dbType, currentOwner string) { fmt.Println() - fmt.Println("⚠️ Cannot release lock") + fmt.Println(glyph.Attention + " Cannot release lock") fmt.Println() fmt.Printf(" Database: %s (%s)\n", database, dbType) fmt.Printf(" Current owner: %s\n", currentOwner) diff --git a/pkg/cmd/internal/templates/plan.go b/pkg/cmd/internal/templates/plan.go index 06b136db7..60fbd1f40 100644 --- a/pkg/cmd/internal/templates/plan.go +++ b/pkg/cmd/internal/templates/plan.go @@ -11,6 +11,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/ui" ) @@ -465,10 +466,10 @@ func WriteErrors(errors []string) { // entry) — the namespaces they name are fully reconciled. func WriteIgnoredNamespaces(ignored, unmatched []string) { if len(ignored) > 0 { - fmt.Printf("ℹ️ Namespaces excluded by ignore_namespaces: %s\n", strings.Join(ignored, ", ")) + fmt.Printf(glyph.Info+" Namespaces excluded by ignore_namespaces: %s\n", strings.Join(ignored, ", ")) } for _, entry := range unmatched { - fmt.Printf("⚠️ ignore_namespaces entry %q matched no namespace and excluded nothing\n", entry) + fmt.Printf(glyph.Attention+" ignore_namespaces entry %q matched no namespace and excluded nothing\n", entry) } if len(ignored) > 0 || len(unmatched) > 0 { fmt.Println() @@ -479,23 +480,27 @@ func WriteIgnoredNamespaces(ignored, unmatched []string) { type UnsafeChange = apitypes.UnsafeChange // WriteUnsafeChangesWarning writes a warning about unsafe changes (for plan output). +// At plan time nothing has been refused yet — the changes await consent, so the +// heading carries Attention, matching the PR plan comment and list-plans. func WriteUnsafeChangesWarning(changes []UnsafeChange) { if len(changes) == 0 { return } - fmt.Println("⛔ Unsafe Changes Detected:") + fmt.Println(glyph.Attention + " Unsafe Changes Detected:") writeUnsafeChangesList(changes) fmt.Println() } // WriteUnsafeChangesBlocked writes the unsafe changes list and instruction to re-run with --allow-unsafe. +// The apply was refused, so Refused attaches to the refusal itself — the heading +// names the blocked apply, not the unsafeness of the changes. func WriteUnsafeChangesBlocked(changes []UnsafeChange, database, environment, schemaDir string) { if len(changes) > 0 { - fmt.Println("⛔ Unsafe Changes Detected:") + fmt.Printf(glyph.Refused+" Apply blocked: %d unsafe change(s) detected\n", len(changes)) writeUnsafeChangesList(changes) fmt.Println() } - fmt.Println("🚨 To proceed with these destructive changes, re-run with --allow-unsafe:") + fmt.Println(glyph.Escalation + " To proceed with these destructive changes, re-run with --allow-unsafe:") fmt.Println() fmt.Printf(" %s apply -s %s -e %s --allow-unsafe\n", cliname.Name(), schemaDir, environment) fmt.Println() @@ -507,7 +512,7 @@ func WriteUnsafeWarningAllowed(changes []UnsafeChange) { return } fmt.Println() - fmt.Println("🚨 Unsafe Changes (--allow-unsafe enabled)") + fmt.Println(glyph.Escalation + " Unsafe Changes (--allow-unsafe enabled)") fmt.Println() fmt.Println("The following unsafe changes will be applied:") writeUnsafeChangesList(changes) diff --git a/pkg/cmd/internal/templates/plan_unsafe_test.go b/pkg/cmd/internal/templates/plan_unsafe_test.go index 1ff733704..b9637f72c 100644 --- a/pkg/cmd/internal/templates/plan_unsafe_test.go +++ b/pkg/cmd/internal/templates/plan_unsafe_test.go @@ -3,6 +3,7 @@ package templates import ( "testing" + "github.com/block/schemabot/pkg/glyph" "github.com/stretchr/testify/assert" ) @@ -29,3 +30,17 @@ func TestWriteUnsafeChangesWarning_SplitsJoinedReasons(t *testing.T) { assert.Contains(t, out, " • audit_log: drop\n") assert.NotContains(t, out, "data; ") } + +// Plan-time unsafe changes await consent, so the heading carries Attention; +// a blocked apply is a refusal, so its heading carries Refused and names the +// refusal itself, with the Escalation instruction for granting consent. +func TestUnsafeChangeHeadings_PlanWarnsApplyRefuses(t *testing.T) { + changes := []UnsafeChange{{Table: "users", Reason: "DROP TABLE removes all data"}} + + planOut := captureStdout(t, func() { WriteUnsafeChangesWarning(changes) }) + assert.Contains(t, planOut, glyph.Attention+" Unsafe Changes Detected:") + + applyOut := captureStdout(t, func() { WriteUnsafeChangesBlocked(changes, "testapp", "staging", ".") }) + assert.Contains(t, applyOut, glyph.Refused+" Apply blocked: 1 unsafe change(s) detected") + assert.Contains(t, applyOut, glyph.Escalation+" To proceed with these destructive changes, re-run with --allow-unsafe:") +} diff --git a/pkg/cmd/internal/templates/progress.go b/pkg/cmd/internal/templates/progress.go index c2a8a8767..84b5a3f06 100644 --- a/pkg/cmd/internal/templates/progress.go +++ b/pkg/cmd/internal/templates/progress.go @@ -10,6 +10,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/ddl" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/storage" "github.com/block/schemabot/pkg/ui" @@ -577,7 +578,7 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel return b.String() case state.Apply.Failed: bar := ui.ProgressBarFailed(ui.RowCopyDisplayPercent(t.PercentComplete, t.RowsCopied)) - fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s ❌ Failed\n", t.TableName, bar) + fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s "+glyph.Failed+" Failed\n", t.TableName, bar) if t.DDL != "" { b.WriteString(formatProgressDDL(t.DDL)) } @@ -786,10 +787,10 @@ func writeThrottleTooltip(b *strings.Builder, t TableProgress) { // protects. A reason whose signal has no tip renders alone so a new // engine signal degrades to raw text rather than a wrong explanation. if tip := ui.ThrottleTip(t.ThrottleReason); tip != "" { - fmt.Fprintf(b, indentDetail+"%sℹ️ Throttled: %s · %s%s\n", ANSIDim, t.ThrottleReason, tip, ANSIReset) + fmt.Fprintf(b, indentDetail+"%s"+glyph.Info+" Throttled: %s · %s%s\n", ANSIDim, t.ThrottleReason, tip, ANSIReset) return } - fmt.Fprintf(b, indentDetail+"%sℹ️ Throttled: %s%s\n", ANSIDim, t.ThrottleReason, ANSIReset) + fmt.Fprintf(b, indentDetail+"%s"+glyph.Info+" Throttled: %s%s\n", ANSIDim, t.ThrottleReason, ANSIReset) } func recoveringIsCopyingRows(t TableProgress) bool { @@ -811,7 +812,7 @@ func formatEstimateExceededTable(t TableProgress, rowsCopied int64, activityBar, b.WriteString(formatProgressDDL(t.DDL)) } fmt.Fprintf(&b, indentDetail+"Rows copied: %s so far\n", ui.FormatNumber(rowsCopied)) - fmt.Fprintf(&b, indentDetail+"%sℹ️ %s%s\n", ANSIDim, ui.EstimateExceededTooltip, ANSIReset) + fmt.Fprintf(&b, indentDetail+"%s"+glyph.Info+" %s%s\n", ANSIDim, ui.EstimateExceededTooltip, ANSIReset) writeThrottleTooltip(&b, t) return b.String() } diff --git a/pkg/cmd/internal/templates/progress_multi.go b/pkg/cmd/internal/templates/progress_multi.go index 702bed94e..f38dcbad5 100644 --- a/pkg/cmd/internal/templates/progress_multi.go +++ b/pkg/cmd/internal/templates/progress_multi.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/block/schemabot/pkg/glyph" "github.com/block/schemabot/pkg/presentation" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/storage" @@ -91,10 +92,10 @@ func writeMultiDeploymentFirstFailure(failure *presentation.Deployment) { return } if failure.Error == "" { - fmt.Printf("\n %s⚠ First failure: %s%s\n", ANSIRed, failure.Deployment, ANSIReset) + fmt.Printf("\n %s"+glyph.Failed+" First failure: %s%s\n", ANSIRed, failure.Deployment, ANSIReset) return } - fmt.Printf("\n %s⚠ First failure: %s — %s%s\n", ANSIRed, failure.Deployment, failure.Error, ANSIReset) + fmt.Printf("\n %s"+glyph.Failed+" First failure: %s — %s%s\n", ANSIRed, failure.Deployment, failure.Error, ANSIReset) } func writeMultiDeploymentNextAction(next presentation.NextAction) { From 7dee41442743f4ae69d17f1864d6351f3ea56b76 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Mon, 24 Aug 2026 17:29:04 +0800 Subject: [PATCH 2/3] fix(cli): assert the lock-refusal heading in integration lock tests Co-Authored-By: Claude Fable 5 --- integration/locks_test.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/integration/locks_test.go b/integration/locks_test.go index 8579790a9..74df4e15b 100644 --- a/integration/locks_test.go +++ b/integration/locks_test.go @@ -504,7 +504,7 @@ CREATE TABLE users ( "--watch=false", ) assert.Error(t, err, "expected CLI apply to fail when PR holds lock") - assertContains(t, out, "Database Locked") + assertContains(t, out, "Apply blocked: database locked") assertContains(t, out, "block/myrepo#123") assertContains(t, out, "--force") }) @@ -574,7 +574,7 @@ CREATE TABLE users ( "--watch=false", ) assert.Error(t, err, "expected CLI apply to fail when other CLI user holds lock") - assertContains(t, out, "Database Locked") + assertContains(t, out, "Apply blocked: database locked") assertContains(t, out, "cli:otheruser@othermachine") }) @@ -667,8 +667,8 @@ CREATE TABLE users ( "--endpoint", endpoint, "--watch=false", ) - // Should NOT show "Database Locked" error - cutover doesn't check locks - assert.NotContains(t, stripANSI(out), "Database Locked", "cutover should not be blocked by locking") + // Should NOT show "Apply blocked: database locked" error - cutover doesn't check locks + assert.NotContains(t, stripANSI(out), "Apply blocked: database locked", "cutover should not be blocked by locking") }) t.Run("stop_works_while_locked", func(t *testing.T) { @@ -678,8 +678,8 @@ CREATE TABLE users ( "-e", "staging", "--endpoint", endpoint, ) - // Should NOT show "Database Locked" error - assert.NotContains(t, stripANSI(out), "Database Locked", "stop should not be blocked by locking") + // Should NOT show "Apply blocked: database locked" error + assert.NotContains(t, stripANSI(out), "Apply blocked: database locked", "stop should not be blocked by locking") }) t.Run("start_works_while_locked", func(t *testing.T) { @@ -690,8 +690,8 @@ CREATE TABLE users ( "--endpoint", endpoint, "--watch=false", ) - // Should NOT show "Database Locked" error - assert.NotContains(t, stripANSI(out), "Database Locked", "start should not be blocked by locking") + // Should NOT show "Apply blocked: database locked" error + assert.NotContains(t, stripANSI(out), "Apply blocked: database locked", "start should not be blocked by locking") }) t.Run("volume_works_while_locked", func(t *testing.T) { @@ -702,8 +702,8 @@ CREATE TABLE users ( "-v", "5", "--endpoint", endpoint, ) - // Should NOT show "Database Locked" error - assert.NotContains(t, stripANSI(out), "Database Locked", "volume should not be blocked by locking") + // Should NOT show "Apply blocked: database locked" error + assert.NotContains(t, stripANSI(out), "Apply blocked: database locked", "volume should not be blocked by locking") }) t.Run("apply_blocked_while_locked", func(t *testing.T) { @@ -716,7 +716,7 @@ CREATE TABLE users ( "--watch=false", ) assert.Error(t, err, "expected apply to fail when locked by another user") - assertContains(t, out, "Database Locked") + assertContains(t, out, "Apply blocked: database locked") }) // Cleanup @@ -762,7 +762,7 @@ CREATE TABLE users ( "--no-lock", ) // Should not show lock conflict - assert.NotContains(t, stripANSI(out), "Database Locked") + assert.NotContains(t, stripANSI(out), "Apply blocked: database locked") // Should proceed with apply assertContains(t, out, "Apply started") waitForApplyFromOutput(t, endpoint, out, "completed", 30*time.Second) @@ -906,7 +906,7 @@ CREATE TABLE users ( "--watch=false", ) assert.Error(t, err, "expected CLI to be blocked on same environment") - assertContains(t, out, "Database Locked") + assertContains(t, out, "Apply blocked: database locked") assertContains(t, out, "block/repo#100") }) @@ -952,7 +952,7 @@ func TestCLI_Locking_DifferentEnvBlocked(t *testing.T) { // Document the intended behavior: // If we had production configured, attempting to apply would fail: // $ schemabot apply -e production # Would be blocked! - // Error: Database Locked + // Error: Apply blocked: database locked // Locked by: block/repo#200 // ... // This prevents concurrent schema changes across environments. From fcbad85a9f67d8720916b45c5b697f9a00e8141e Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Wed, 26 Aug 2026 00:13:45 +0800 Subject: [PATCH 3/3] docs: describe the shared plan/apply severity reading for the CLI Co-Authored-By: Claude Fable 5 --- docs/lint-and-safety-levels.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/lint-and-safety-levels.md b/docs/lint-and-safety-levels.md index bff15c3fc..9da507525 100644 --- a/docs/lint-and-safety-levels.md +++ b/docs/lint-and-safety-levels.md @@ -172,5 +172,5 @@ Presentation notes: the collapsed header. Short lists render inline. - Quoted identifiers and types inside finding messages (index names, column types) render as inline code. -- The CLI uses ⛔ for unsafe changes at both plan and apply time, since the - terminal is both the review surface and the gate. +- The CLI and the plan comment share the same severity reading: ⚠️ marks + unsafe changes awaiting review at plan time, and ⛔ marks the refused apply.