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
173 changes: 173 additions & 0 deletions internal/contractlint/fo_contract_path_portability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// ABOUTME: Structural guard for workflow-portable FO contract paths.
// ABOUTME: Distinguishes operational instructions from explicit examples and install hints.
package contractlint

import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)

type foContractPathLeak struct {
Line int
Text string
Kind string
}

func TestFOContractPathPortabilityDiscriminators(t *testing.T) {
cases := []struct {
name string
body string
wantLeak bool
}{
{
name: "operational docs dev README rule fails",
body: "| allowed-process | `docs/dev/README.md`; `{workflow_dir}/README.md` | The FO may edit this process doc. |",
wantLeak: true,
},
{
name: "operational docs dev mods rule fails",
body: "| blocked-product | `docs/dev/_mods/**` | Mods go through workers. |",
wantLeak: true,
},
{
name: "operational universal state checkout rule fails",
body: "| allowed-state | `.spacedock-state/**`; `{workflow_dir}/_archive/**` | State writes. |",
wantLeak: true,
},
{
name: "explicit state checkout example passes",
body: "Example: a workflow may declare `state: .spacedock-state`; use the resolved state checkout, not this literal.",
wantLeak: false,
},
{
name: "survey discovery signal passes",
body: "Survey discovery signal: probe `.spacedock-state`, `docs/**/.spacedock-state`, and `_mods` as possible workflow hints.",
wantLeak: false,
},
{
name: "operational local main drift repo rebuild fails",
body: "- **local-main-drift** -> `git -C {repo} fetch origin {drift.trunk} && git -C {repo} merge --ff-only origin/{drift.trunk} && cd {repo} && go build -o spacedock ./cmd/spacedock`.",
wantLeak: true,
},
{
name: "source build install hint passes",
body: "Install hint: when the launcher binary is absent, build it from a Spacedock source checkout with `go build -o spacedock ./cmd/spacedock`.",
wantLeak: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
leaks := scanFOContractPathLeaks(tc.name, tc.body)
if tc.wantLeak && len(leaks) == 0 {
t.Fatal("expected a path portability leak, got none")
}
if !tc.wantLeak && len(leaks) > 0 {
t.Fatalf("unexpected path portability leaks: %v", leaks)
}
})
}
}

func TestShippedFOContractPathsAreWorkflowPortable(t *testing.T) {
files := foContractPathPortabilityFiles(t)
if len(files) == 0 {
t.Fatal("scanned zero FO contract files; guard would pass vacuously")
}
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
t.Errorf("read %s: %v", path, err)
continue
}
for _, leak := range scanFOContractPathLeaks(path, string(data)) {
rel, _ := filepath.Rel(repoRoot(t), path)
t.Errorf("%s:%d: %s: %s", rel, leak.Line, leak.Kind, strings.TrimSpace(leak.Text))
}
}
}

func foContractPathPortabilityFiles(t *testing.T) []string {
t.Helper()
root := repoRoot(t)
files := []string{
filepath.Join(root, "skills", "first-officer", "SKILL.md"),
filepath.Join(root, "skills", "fo-dispatch-recovery", "SKILL.md"),
filepath.Join(root, "skills", "fo-status-viewer", "SKILL.md"),
filepath.Join(root, "skills", "fo-write-core", "SKILL.md"),
filepath.Join(root, "skills", "feedback-rejection-flow", "SKILL.md"),
filepath.Join(root, "skills", "present-gate", "SKILL.md"),
filepath.Join(root, "skills", "using-legacy-claude-team", "SKILL.md"),
}
refs, err := filepath.Glob(filepath.Join(root, "skills", "first-officer", "references", "*.md"))
if err != nil {
t.Fatalf("glob FO references: %v", err)
}
files = append(files, refs...)
sort.Strings(files)
return files
}

func scanFOContractPathLeaks(name, body string) []foContractPathLeak {
lines := strings.Split(body, "\n")
var leaks []foContractPathLeak
for i, line := range lines {
context := localFOContractContext(lines, i)
for _, check := range []struct {
token string
kind string
}{
{token: "docs/dev/README.md", kind: "hardcoded Spacedock workflow README"},
{token: "docs/dev/_mods", kind: "hardcoded Spacedock workflow mod path"},
{token: ".spacedock-state/**", kind: "universal state checkout spelling"},
} {
if strings.Contains(line, check.token) && !allowedFOContractExampleContext(context) {
leaks = append(leaks, foContractPathLeak{Line: i + 1, Text: line, Kind: check.kind})
}
}
if strings.Contains(line, "cd {repo} && go build -o spacedock ./cmd/spacedock") &&
!allowedFOLauncherBuildContext(context) {
leaks = append(leaks, foContractPathLeak{Line: i + 1, Text: line, Kind: "managed-repo launcher rebuild"})
}
}
return leaks
}

func localFOContractContext(lines []string, i int) string {
start := i - 3
if start < 0 {
start = 0
}
end := i + 4
if end > len(lines) {
end = len(lines)
}
return strings.ToLower(strings.Join(lines[start:end], "\n"))
}

func allowedFOContractExampleContext(context string) bool {
for _, marker := range []string{
"example",
"placeholder",
"install hint",
"source-build",
"source build",
"discovery signal",
"probe",
"survey",
} {
if strings.Contains(context, marker) {
return true
}
}
return false
}

func allowedFOLauncherBuildContext(context string) bool {
return strings.Contains(context, "install hint") ||
strings.Contains(context, "source-build") ||
strings.Contains(context, "source build")
}
159 changes: 120 additions & 39 deletions internal/contractlint/fo_write_core_mutation_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,47 +19,85 @@ func TestFOWriteCoreMutationGateClassifiesTargets(t *testing.T) {
classifier := parseFOWriteClassifierTable(t, table)

cases := []struct {
name string
ctx foWriteWorkflowContext
path string
want string
}{
{".spacedock-state/task/index.md", "allowed-state"},
{"docs/dev/README.md", "allowed-process"},
{"cmd/spacedock/main.go", "blocked-product"},
{"internal/status/mutate.go", "blocked-product"},
{"internal/status/mutate_test.go", "blocked-product"},
{"skills/fo-write-core/SKILL.md", "blocked-product"},
{"agents/first-officer.md", "blocked-product"},
{"references/legacy.md", "blocked-product"},
{"plugin.json", "blocked-product"},
{".github/workflows/runtime-live-e2e.yml", "blocked-product"},
{"docs/site/reference/command-reference.md", "blocked-product"},
{"docs/specs/state-behavior-extension.md", "blocked-product"},
{"docs/roadmap/0250-fo-behavioral-discipline/index.md", "blocked-product"},
{"fixtures/entity-label-drive/README.md", "blocked-product"},
{"docs/dev/_mods/pr-merge.md", "blocked-product"},
{
name: "resolved synthetic state entity",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "/tmp/acme-flow/.state/task/index.md",
want: "allowed-state",
},
{
name: "resolved synthetic archive root",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "/tmp/acme-flow/.state/_archive/done/index.md",
want: "allowed-state",
},
{
name: "absolute workflow README",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "/tmp/acme-flow/README.md",
want: "allowed-process",
},
{
name: "relative workflow README",
ctx: syntheticFOWriteContext("workflows/acme", "workflows/acme/.state"),
path: "workflows/acme/README.md",
want: "allowed-process",
},
{
name: "docs dev README is not special outside the discovered workflow",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "docs/dev/README.md",
want: "blocked-product",
},
{
name: "registered mods are product work",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "/tmp/acme-flow/_mods/pr-merge.md",
want: "blocked-product",
},
{
name: "unmatched code defaults to product",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "internal/status/mutate.go",
want: "blocked-product",
},
{
name: "shipped skill scaffolding defaults to product",
ctx: syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state"),
path: "skills/fo-write-core/SKILL.md",
want: "blocked-product",
},
}
for _, tc := range cases {
if got := classifyFOWriteTarget(classifier, tc.path); got != tc.want {
t.Errorf("classify %q = %q, want %q", tc.path, got, tc.want)
}
t.Run(tc.name, func(t *testing.T) {
if got := classifyFOWriteTarget(classifier, tc.ctx, tc.path); got != tc.want {
t.Errorf("classify %q = %q, want %q", tc.path, got, tc.want)
}
})
}
}

func TestFOWriteCoreMutationGateRequiresExactOverride(t *testing.T) {
table := readFOWriteClassifierTable(t)
classifier := parseFOWriteClassifierTable(t, table)
ctx := syntheticFOWriteContext("/tmp/acme-flow", "/tmp/acme-flow/.state")

target := "internal/status/mutate.go"
if foWriteOverrideAllows(target, "you may fix the code directly") {
t.Fatalf("broad direct-edit text unexpectedly allowed %q", target)
}
if foWriteMayWrite(classifier, target, "you may fix the code directly") {
if foWriteMayWrite(classifier, ctx, target, "you may fix the code directly") {
t.Fatalf("broad direct-edit text must not allow blocked product target %q", target)
}
if !foWriteMayWrite(classifier, target, "you may directly edit internal/status/mutate.go for this task") {
if !foWriteMayWrite(classifier, ctx, target, "you may directly edit internal/status/mutate.go for this task") {
t.Fatalf("exact target grant must allow %q after blocked-product classification", target)
}
if foWriteMayWrite(classifier, "internal/status/parse.go", "you may directly edit internal/status/mutate.go for this task") {
if foWriteMayWrite(classifier, ctx, "internal/status/parse.go", "you may directly edit internal/status/mutate.go for this task") {
t.Fatalf("exact grant for mutate.go must not allow a different product path")
}
}
Expand Down Expand Up @@ -113,31 +151,74 @@ func parseFOWriteClassifierTable(t *testing.T, table string) map[string][]string
return out
}

func classifyFOWriteTarget(classifier map[string][]string, target string) string {
for _, class := range []string{"blocked-product", "allowed-state", "allowed-process"} {
for _, pattern := range classifier[class] {
if pathPatternMatches(pattern, target) {
return class
}
}
type foWriteWorkflowContext struct {
workflowDir string
stateCheckout string
entityPath string
archiveRoots []string
modRoots []string
}

func syntheticFOWriteContext(workflowDir, stateCheckout string) foWriteWorkflowContext {
return foWriteWorkflowContext{
workflowDir: cleanFOWritePath(workflowDir),
stateCheckout: cleanFOWritePath(stateCheckout),
entityPath: cleanFOWritePath(filepath.Join(stateCheckout, "task", "index.md")),
archiveRoots: []string{
cleanFOWritePath(filepath.Join(stateCheckout, "_archive")),
},
modRoots: []string{
cleanFOWritePath(filepath.Join(workflowDir, "_mods")),
},
}
}

func classifyFOWriteTarget(classifier map[string][]string, ctx foWriteWorkflowContext, target string) string {
target = cleanFOWritePath(target)
if classifierHasSource(classifier, "blocked-product", "registered mods plus every target not classified as state/process") &&
anyFOWritePathPrefix(target, ctx.modRoots) {
return "blocked-product"
}
if classifierHasSource(classifier, "allowed-state", "resolved state/entity/archive paths") &&
(target == ctx.entityPath || hasFOWritePathPrefix(target, ctx.stateCheckout) || anyFOWritePathPrefix(target, ctx.archiveRoots)) {
return "allowed-state"
}
if classifierHasSource(classifier, "allowed-process", "{workflow_dir}/README.md only") &&
target == cleanFOWritePath(filepath.Join(ctx.workflowDir, "README.md")) {
return "allowed-process"
}
return "blocked-product"
}

func pathPatternMatches(pattern, target string) bool {
pattern = strings.TrimSpace(pattern)
switch {
case strings.HasSuffix(pattern, "/**"):
return strings.HasPrefix(target, strings.TrimSuffix(pattern, "**"))
case strings.HasPrefix(pattern, "**/*"):
return strings.HasSuffix(target, strings.TrimPrefix(pattern, "**/*"))
default:
return target == pattern
func classifierHasSource(classifier map[string][]string, class, want string) bool {
for _, source := range classifier[class] {
if source == want {
return true
}
}
return false
}

func cleanFOWritePath(path string) string {
return filepath.ToSlash(filepath.Clean(path))
}

func anyFOWritePathPrefix(target string, roots []string) bool {
for _, root := range roots {
if hasFOWritePathPrefix(target, root) {
return true
}
}
return false
}

func hasFOWritePathPrefix(target, root string) bool {
root = strings.TrimSuffix(cleanFOWritePath(root), "/")
return target == root || strings.HasPrefix(target, root+"/")
}

func foWriteMayWrite(classifier map[string][]string, target, grant string) bool {
switch classifyFOWriteTarget(classifier, target) {
func foWriteMayWrite(classifier map[string][]string, ctx foWriteWorkflowContext, target, grant string) bool {
switch classifyFOWriteTarget(classifier, ctx, target) {
case "allowed-state", "allowed-process":
return true
default:
Expand Down
Loading
Loading