Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cmd/sin-code/internal/spec/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ import (
// DefaultCheckTimeout is the per-criterion timeout if not configured.
const DefaultCheckTimeout = 60 * time.Second

// Policy controls how spec-drift failures affect exit codes (issue #157).
// - PolicyOff: never block, exit 0 even with must-failures
// - PolicyWarn: print warnings, exit 0 (advisory mode)
// - PolicyError: block on must-failures, exit 1 (CI gate mode)
type Policy string

const (
PolicyOff Policy = "off"
PolicyWarn Policy = "warn"
PolicyError Policy = "error"
)

// ParsePolicy normalises a raw policy string. Unknown values
// default to PolicyError (fail-closed; the verify gate is sacred).
func ParsePolicy(s string) Policy {
switch strings.ToLower(strings.TrimSpace(s)) {
case "off":
return PolicyOff
case "warn", "warning":
return PolicyWarn
case "error", "strict", "":
return PolicyError
default:
return PolicyError
}
}

// CheckResult is the outcome of running a single criterion's verify
// command. The ID matches the Criterion.ID from the parsed spec.
type CheckResult struct {
Expand Down Expand Up @@ -54,6 +81,18 @@ func (r *CheckReport) HasFailures() bool {
return false
}

// ShouldBlock returns true if the report should cause the calling
// CLI to exit non-zero, given the active policy. PolicyOff and
// PolicyWarn never block; PolicyError blocks on must-failures.
func (r *CheckReport) ShouldBlock(p Policy) bool {
switch p {
case PolicyOff, PolicyWarn:
return false
default:
return r.HasFailures()
}
}

// Check runs every criterion's verify: command in s. The per-command
// timeout is enforced via context.WithTimeout. Output is truncated to
// 4KB per criterion to keep the report small.
Expand Down
65 changes: 65 additions & 0 deletions cmd/sin-code/internal/spec/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for issue #157 — spec.drift policy key.
package spec

import "testing"

func TestParsePolicy(t *testing.T) {
cases := map[string]Policy{
"off": PolicyOff,
"OFF": PolicyOff,
" Off ": PolicyOff,
"warn": PolicyWarn,
"warning": PolicyWarn,
"error": PolicyError,
"strict": PolicyError,
"": PolicyError, // empty defaults to error (fail-closed)
"bogus": PolicyError, // unknown defaults to error
}
for in, want := range cases {
if got := ParsePolicy(in); got != want {
t.Errorf("ParsePolicy(%q) = %q, want %q", in, got, want)
}
}
}

func TestCheckReport_ShouldBlock(t *testing.T) {
// Empty report: no block.
empty := &CheckReport{}
if empty.ShouldBlock(PolicyError) {
t.Error("empty report should not block even in error mode")
}
if empty.ShouldBlock(PolicyOff) {
t.Error("empty report should not block in off mode")
}

// Report with a must-failure.
failing := &CheckReport{
Results: []CheckResult{{ID: "R1", Passed: false, Priority: Must}},
}
if !failing.ShouldBlock(PolicyError) {
t.Error("must-failure should block in error mode")
}
if failing.ShouldBlock(PolicyWarn) {
t.Error("must-failure should NOT block in warn mode")
}
if failing.ShouldBlock(PolicyOff) {
t.Error("must-failure should NOT block in off mode")
}

// Report with only should-priority failures: never blocks.
shouldFail := &CheckReport{
Results: []CheckResult{{ID: "R1", Passed: false, Priority: Should}},
}
if shouldFail.ShouldBlock(PolicyError) {
t.Error("should-failure should never block")
}

// Skipped failures: never block.
skipped := &CheckReport{
Results: []CheckResult{{ID: "R1", Passed: false, Skipped: true, Priority: Must}},
}
if skipped.ShouldBlock(PolicyError) {
t.Error("skipped failures should never block")
}
}
20 changes: 17 additions & 3 deletions cmd/sin-code/spec_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
timeout time.Duration
drift bool
root string
policy string
)
c := &cobra.Command{
Use: "check [file.spec.md]",
Expand All @@ -163,10 +164,22 @@
sin-code spec check --all --json # machine-readable report
sin-code spec check --all --drift # + signature drift
sin-code spec check --all --drift --root ./cmd/... # scope the walk
sin-code spec check --timeout 30s ... # override per-criterion timeout`,
sin-code spec check --timeout 30s ... # override per-criterion timeout
sin-code spec check --policy off|warn|error # drift strictness (issue #157)`,
// The --policy default reads from SIN_SPEC_DRIFT env var, then falls

Check failure on line 169 in cmd/sin-code/spec_cmd.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
// back to "error" (CI gate mode; the verify gate is sacred).
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Resolve policy: --policy flag > SIN_SPEC_DRIFT env > "error".
polRaw := policy
if polRaw == "" {
polRaw = os.Getenv("SIN_SPEC_DRIFT")
}
pol := spec.ParsePolicy(polRaw)
if !asJSON {
fmt.Fprintf(cmd.OutOrStdout(), "policy: %s\n", pol)
}
paths, err := collectSpecPaths(args, all)
if err != nil {
return err
Expand Down Expand Up @@ -229,8 +242,8 @@
return err
}
}
if anyFailure {
return fmt.Errorf("spec check: at least one must-priority criterion or signature drifted")
if anyFailure && pol == spec.PolicyError {
return fmt.Errorf("spec check: at least one must-priority criterion or signature drifted (policy=%s)", pol)
}
return nil
},
Expand All @@ -240,6 +253,7 @@
c.Flags().DurationVar(&timeout, "timeout", spec.DefaultCheckTimeout, "per-criterion timeout")
c.Flags().BoolVar(&drift, "drift", false, "also run the Spec<->Code signature drift check")
c.Flags().StringVar(&root, "root", ".", "root directory for the signature drift walk")
c.Flags().StringVar(&policy, "policy", "", "drift strictness: off|warn|error (overrides SIN_SPEC_DRIFT env; default error)")
return c
}

Expand Down
7 changes: 7 additions & 0 deletions scripts/spec-drift-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ set -euo pipefail
# see the right tree.
cd "$(git rev-parse --show-toplevel)"

# Policy: SIN_SPEC_DRIFT env var overrides the default (error).
# off — never block (developers opt-in)
# warn — print warnings, never block
# error — block on must-failures (CI gate mode; default)
: "${SIN_SPEC_DRIFT:=error}"
export SIN_SPEC_DRIFT

# Locate the sin-code binary. Prefer the user's PATH; fall back to the
# locally-built binary at ./sin-code (dev workflow).
SIN_BIN="${SIN_BIN:-sin-code}"
Expand Down
Loading