Skip to content
Open
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
37 changes: 26 additions & 11 deletions architecture/CONTRACT-TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
# CONTRACT-{NAME}.{MAJOR}.{MINOR}
# CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR}

<!-- Copy this file to create a new contract.
Replace all {placeholders} with actual values.
Remove these HTML comments when done. -->
Remove these HTML comments when done.

Filename: CONTRACT-{NAME}.{MAJOR}.{MINOR}.md ← filenames stay unnamespaced
Title: # CONTRACT-{NAMESPACE}:{NAME}.{MAJOR}.{MINOR}
In-source: CONTRACT:{NAMESPACE}:{NAME}.{MAJOR}.{MINOR}

{NAMESPACE} = your repo's namespace from .rebarrc contract_namespace
(e.g. github.com/myorg/myrepo)
{NAME} = contract ID (e.g. AUTH, BLOBSTORE, KEY-EXCHANGE)

Legacy form CONTRACT:{NAME}.{v} is still valid — both are recognised
by rebar's enforcement and steward scan. Use namespaced form when this
contract may be referenced from other repos. -->

<!-- VERSIONING:
- When this contract is superseded, add: SUPERSEDED BY: CONTRACT-{NAME}.{NEW}
and set its Status: to `superseded` (terminal — excluded from maturity weighting)
- When this contract supersedes another, add: SUPERSEDES: CONTRACT-{NAME}.{OLD}
- When superseded: SUPERSEDED BY: CONTRACT-{NAMESPACE}:{NAME}.{NEW}
and set Status: superseded (terminal — excluded from maturity weighting)
- When superseding: SUPERSEDES: CONTRACT-{NAMESPACE}:{NAME}.{OLD}
-->

**Version:** {MAJOR}.{MINOR}
Expand Down Expand Up @@ -112,7 +124,7 @@ type BlobStore interface {
<!-- What does this component depend on? Other contracts, external services,
configuration. -->

- Depends on: `CONTRACT:I2-KEY-EXCHANGE.1.0` for encryption keys
- Depends on: `CONTRACT:{NAMESPACE}:KEY-EXCHANGE.1.0` for encryption keys
- Configuration: `BLOBSTORE_PATH` environment variable
- External: none (self-contained)

Expand Down Expand Up @@ -142,15 +154,18 @@ type BlobStore interface {
OPTIONAL otherwise. Without a deadline, superseded contracts accumulate
indefinite-state lag (see filedag's C9-ABAC retirement-lag finding). -->

- **Predecessor:** `CONTRACT-<ID>.<old-version>` — retirement criterion: `grep -rn "<old-id>"` returns zero
- **Predecessor:** `CONTRACT-<NAME>.<old-version>` — retirement criterion: `grep -rEn "CONTRACT:([^:]+:)?<NAME>\."` returns zero
- **Migration deadline:** YYYY-MM-DD or named phase boundary
- **Migration owner:** [team or person responsible for the cutover]

## Implementing Files

<!-- List all files that implement this contract.
Keep updated — or regenerate with:
grep -rn "CONTRACT:{NAME}" src/ internal/ client/
Each implementing file should carry a header comment:
// CONTRACT:{NAMESPACE}:{NAME}.{MAJOR}.{MINOR}
Both namespaced and legacy forms are recognised.
Regenerate with:
grep -rEn "CONTRACT:([^:]+:)?{NAME}\." src/ internal/ client/
-->

- `internal/blobstore/file.go` — file-backed implementation
Expand Down Expand Up @@ -203,8 +218,8 @@ type BlobStore interface {
the companion without bumping the contract version.

The companion lives alongside the contract in architecture/:
architecture/CONTRACT-C1-BLOBSTORE.2.1.md ← the contract
architecture/CONTRACT-C1-BLOBSTORE.impl.md ← the companion
architecture/CONTRACT-BLOBSTORE.2.1.md ← the contract (filename unnamespaced)
architecture/CONTRACT-BLOBSTORE.impl.md ← the companion
-->

## Change History
Expand Down
64 changes: 58 additions & 6 deletions cli/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,89 @@ package cmd
import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
"github.com/willackerly/rebar/cli/internal/scripts"
)

var checkStrict bool
var checkPreCommit bool

var checkCmd = &cobra.Command{
Use: "check",
Short: "Run all enforcement checks",
Long: `Runs steward and CI checks via scripts/ci-check.sh.`,
RunE: runCheck,
Short: "Run enforcement checks",
Long: `Run enforcement checks against the current project.

Without flags: runs the full check suite via scripts/ci-check.sh.
--pre-commit: runs fast pre-commit checks (TODOs, contract refs) directly
from the rebar installation — no project scripts involved.
Called by the .git/hooks/pre-commit hook.`,
RunE: runCheck,
}

func init() {
checkCmd.Flags().BoolVar(&checkStrict, "strict", true, "exit 1 on any failure")
checkCmd.Flags().BoolVar(&checkStrict, "strict", true, "exit 1 on any failure (full check only)")
checkCmd.Flags().BoolVar(&checkPreCommit, "pre-commit", false, "run fast pre-commit checks from rebar install")
}

func runCheck(cmd *cobra.Command, args []string) error {
if checkPreCommit {
return runPreCommitChecks()
}

scriptArgs := []string{}
if checkStrict {
scriptArgs = append(scriptArgs, "--strict")
}

exitCode, err := scripts.RunPassthrough(cfg.ScriptsDir, "ci-check.sh", scriptArgs...)
if err != nil {
return fmt.Errorf("running checks: %w", err)
}

if exitCode != 0 {
os.Exit(exitCode)
}
return nil
}

// runPreCommitChecks runs the fast enforcement subset from the rebar
// installation's scripts/ directory — not from the project's scripts/.
// This avoids the circular chain:
// pre-commit.sh → rebar check --pre-commit → rebar home scripts
// The project's scripts/pre-commit.sh is a 3-line entry point only.
func runPreCommitChecks() error {
rebarRoot := findRebarRoot()
if rebarRoot == "" {
fmt.Fprintln(os.Stderr, "pre-commit: rebar installation not found — skipping checks")
fmt.Fprintln(os.Stderr, " Set REBAR_ROOT or ensure rebar is installed at ~/.rebar")
return nil // fail-open: don't block commits on missing rebar
}

rebarScripts := filepath.Join(rebarRoot, "scripts")

// Fast checks appropriate for pre-commit (<5s total).
// These run from the rebar install, not the project's scripts/.
fastChecks := []string{
"check-todos.sh",
"check-contract-refs.sh",
}

failed := 0
for _, name := range fastChecks {
if _, err := os.Stat(filepath.Join(rebarScripts, name)); err != nil {
continue // script not present in this rebar version — skip
}
fmt.Printf(" checking: %s\n", name)
exitCode, err := scripts.RunPassthrough(rebarScripts, name)
if err != nil || exitCode != 0 {
failed++
}
}

if failed > 0 {
fmt.Fprintf(os.Stderr, "\n%d pre-commit check(s) failed. Fix above or skip with --no-verify.\n", failed)
os.Exit(1)
}
fmt.Println(" pre-commit checks passed.")
return nil
}
6 changes: 4 additions & 2 deletions cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ _None currently._
}
}
if copiedScripts > 0 {
fmt.Printf(" Created scripts/ (%d scripts incl. cold-start-checks.sh, ci-check.sh, inbox-watch.sh)\n", copiedScripts)
fmt.Printf(" Created scripts/ (%d scripts: pre-commit.sh, ci-check.sh — thin wrappers calling rebar)\n", copiedScripts)
created++
}
}
Expand Down Expand Up @@ -493,7 +493,9 @@ func findRebarRoot() string {
if dir == "" {
return false
}
_, err := os.Stat(filepath.Join(dir, "templates", "project-bootstrap", "scripts", "steward.sh"))
// setup-rebar.sh exists in both installed framework dirs and source checkouts;
// steward.sh was removed from project-bootstrap/scripts/ in the thin-scripts refactor.
_, err := os.Stat(filepath.Join(dir, "setup-rebar.sh"))
return err == nil
}

Expand Down
49 changes: 32 additions & 17 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import (
)

type Config struct {
RepoRoot string
RebarDir string // .rebar/
Tier int // 1, 2, or 3
Version string // from .rebar-version
ScriptsDir string // scripts/
AgentsDir string // agents/
BinDir string // bin/
RepoRoot string
RebarDir string // .rebar/
Tier int // 1, 2, or 3
Version string // from .rebar-version
ScriptsDir string // scripts/
AgentsDir string // agents/
BinDir string // bin/
ContractNamespace string // contract_namespace from .rebarrc (e.g. github.com/org/repo)
}

// Load reads configuration from .rebarrc and .rebar-version, respecting
Expand All @@ -39,9 +40,12 @@ func Load(repoRoot string) (*Config, error) {
}
} else {
// Read from .rebarrc
tier, err := readRebarRC(filepath.Join(repoRoot, ".rebarrc"))
if err == nil && tier >= 1 && tier <= 3 {
c.Tier = tier
rc, err := readRebarRC(filepath.Join(repoRoot, ".rebarrc"))
if err == nil {
if rc.tier >= 1 && rc.tier <= 3 {
c.Tier = rc.tier
}
c.ContractNamespace = rc.namespace
}
}

Expand Down Expand Up @@ -81,15 +85,20 @@ func FindRepoRoot(dir string) (string, error) {
}
}

// readRebarRC parses a .rebarrc file for the tier setting.
// Format: key = value lines, comments with #.
func readRebarRC(path string) (int, error) {
type rebarRC struct {
tier int
namespace string
}

// readRebarRC parses a .rebarrc file. Format: key = value lines, # comments.
func readRebarRC(path string) (rebarRC, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
return rebarRC{}, err
}
defer f.Close()

var rc rebarRC
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
Expand All @@ -102,11 +111,17 @@ func readRebarRC(path string) (int, error) {
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if strings.EqualFold(key, "tier") || strings.EqualFold(key, "rebar_tier") {
return strconv.Atoi(val)
switch {
case strings.EqualFold(key, "tier") || strings.EqualFold(key, "rebar_tier"):
rc.tier, _ = strconv.Atoi(val)
case strings.EqualFold(key, "contract_namespace"):
rc.namespace = val
}
}
return 0, fmt.Errorf("tier not found in .rebarrc")
if rc.tier == 0 {
return rc, fmt.Errorf("tier not found in .rebarrc")
}
return rc, nil
}

// EnsureRebarDir creates the .rebar/ directory structure.
Expand Down
8 changes: 6 additions & 2 deletions scripts/steward.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,19 @@ scan_contract() {
completeness="fail"
fi

# Implementing files: grep for CONTRACT:<id> across the project
# Implementing files: grep for CONTRACT:<id> across the project.
# Matches both legacy (CONTRACT:<id>.<v>) and namespaced
# (CONTRACT:<namespace>:<id>.<v>) references so repos in transition
# are scanned correctly regardless of which form they use.
local impl_files=()
local test_files=()
local impl_pattern="CONTRACT:([a-zA-Z0-9][a-zA-Z0-9_./-]+:)?${id}\\."

while IFS= read -r line; do
local filepath
filepath="$(echo "$line" | cut -d: -f1)"
impl_files+=("$filepath")
done < <(grep -rn "CONTRACT:${id}" "$PROJECT_ROOT" \
done < <(grep -rEn "$impl_pattern" "$PROJECT_ROOT" \
--include='*.go' --include='*.ts' --include='*.js' --include='*.py' \
--include='*.rs' --include='*.java' --include='*.rb' --include='*.c' \
--include='*.cpp' --include='*.h' --include='*.cs' --include='*.swift' \
Expand Down
Loading