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
13 changes: 11 additions & 2 deletions .github/tests/test_docs_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,19 @@ def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None:


class DocumentationContractTests(unittest.TestCase):
def test_required_ci_validates_documentation(self) -> None:
def test_required_ci_validates_flow_sdk_and_documentation(self) -> None:
ci = (REPO / ".github" / "workflows" / "ci.yml").read_text()
steps = workflow_jobs(ci)["flow-sdk"]["steps"]
self.assertIn("npm ci && npm run docs:check", [step.get("run") for step in steps])
commands = [step.get("run") for step in steps]
self.assertIn(
"npm ci && npm run test:flow-sdk && npm run docs:check", commands
)
self.assertTrue(
any(
command and "TestSoftwareDeliverySugar" in command
for command in commands
)
)

def test_pages_deployment_is_main_only_and_release_independent(self) -> None:
workflow = (REPO / ".github" / "workflows" / "docs-pages.yml").read_text()
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ jobs:
go-version-file: boatstack/go.mod
cache-dependency-path: boatstack/go.sum
- name: Build TypeScript frontends and documentation
run: npm ci && npm run docs:check
run: npm ci && npm run test:flow-sdk && npm run docs:check
- name: Prove frontend canonical equivalence
working-directory: boatstack
env:
BOATSTACK_REQUIRE_FLOW_FRONTEND: '1'
run: go test ./controlprogram -run 'TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint|TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime'
run: go test ./controlprogram -run 'TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint|TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime|TestSoftwareDeliverySugar'

component:
name: component-${{ matrix.name }}
Expand Down
214 changes: 214 additions & 0 deletions boatstack/controlprogram/frontend_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -61,6 +62,63 @@ func TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint(t *testing.T) {
}
}

func TestSoftwareDeliverySugarIsByteAndProjectionEquivalent(t *testing.T) {
// control-law: software-delivery-sugar-derives-only-canonical-wiring
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate frontend fixtures")
}
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
repositoryRoot := filepath.Dir(moduleRoot)
frontend := filepath.Join(repositoryRoot, "node_modules", ".bin", "boatstack-flow-frontend")
if runtime.GOOS == "windows" {
frontend += ".cmd"
}
if _, err := os.Stat(frontend); err != nil {
t.Skip("Flow frontend dependencies are not installed")
}
compile := func(name string) []byte {
t.Helper()
raw, err := exec.Command(frontend, filepath.Join(moduleRoot, "testdata", "control-programs", name)).CombinedOutput()
if err != nil {
t.Fatalf("compile %s: %v\n%s", name, err, raw)
}
return raw
}
manualRaw := compile("product-delivery-planning-package-manual.flow.ts")
helperRaw := compile("product-delivery-planning-package.flow.ts")
if !bytes.Equal(manualRaw, helperRaw) {
t.Fatalf("manual and helper raw IR differ\nmanual: %s\nhelper: %s", manualRaw, helperRaw)
}
resolver, err := softwareflow.NewResolver(context.Background())
if err != nil {
t.Fatal(err)
}
assets := controlprogram.RepositoryAssetResolver{Repository: repositoryRoot}
manual, err := controlprogram.LoadWithAssets(bytes.NewReader(manualRaw), resolver, assets)
if err != nil {
t.Fatal(err)
}
helper, err := controlprogram.LoadWithAssets(bytes.NewReader(helperRaw), resolver, assets)
if err != nil {
t.Fatal(err)
}
if manual.Fingerprint != helper.Fingerprint || !reflect.DeepEqual(manual.Document, helper.Document) {
t.Fatalf("canonical programs differ: manual=%s helper=%s", manual.Fingerprint, helper.Fingerprint)
}
manualProjections, err := softwareflow.GenerateProjections(manual, hostprojection.CanonicalIDs())
if err != nil {
t.Fatal(err)
}
helperProjections, err := softwareflow.GenerateProjections(helper, hostprojection.CanonicalIDs())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(manualProjections, helperProjections) {
t.Fatalf("manual and helper generated projections differ")
}
}

func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) {
// control-law: repositories-own-entry-target-and-transition-policy
_, file, _, ok := runtime.Caller(0)
Expand Down Expand Up @@ -289,3 +347,159 @@ func TestTypeScriptFrontendRejectsUnboundLocalImports(t *testing.T) {
t.Fatalf("local import result = %v\n%s", err, output)
}
}

func TestTypeScriptFrontendKeepsDeclarativeExpressionBoundary(t *testing.T) {
// control-law: software-delivery-sugar-does-not-widen-the-frontend-language
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate frontend")
}
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend")
if runtime.GOOS == "windows" {
frontend += ".cmd"
}
if _, err := os.Stat(frontend); err != nil {
t.Skip("Flow frontend dependencies are not installed")
}
cases := []struct {
name string
source string
message string
}{
{"object spread", "const base = {};\nexport default defineFlow({ ...base });\n", "explicit property assignments"},
{"array spread", "const values = [];\nexport default defineFlow({ facets: [...values] });\n", "Flow expression is not declarative"},
{"property call", "export default defineFlow.call(undefined, {});\n", "named trusted SDK imports"},
{"callback and map", "const values = [];\nexport default defineFlow({ facets: values.map((value) => value) });\n", "named trusted SDK imports"},
{"local function", "function local() { return {}; }\nexport default defineFlow(local());\n", "trusted imports and one default export"},
{"computed property", "export default defineFlow({ [\"id\"]: \"example\" });\n", "static identifiers or literals"},
{"arbitrary statement", "if (true) {}\nexport default defineFlow({});\n", "trusted imports and one default export"},
{"mutation", "const value = {};\nvalue.id = \"example\";\nexport default defineFlow(value);\n", "trusted imports and one default export"},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
source := filepath.Join(directory, "invalid.flow.ts")
content := "import { defineFlow } from \"@operatorstack/boatstack\";\n" + test.source
if err := os.WriteFile(source, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
output, err := exec.Command(frontend, source).CombinedOutput()
if err == nil || !strings.Contains(string(output), test.message) {
t.Fatalf("frontend result = %v\n%s", err, output)
}
})
}
}

func TestSoftwareDeliverySugarLeavesUnknownResolversForTrustedInputValidation(t *testing.T) {
// control-law: trusted-input-boundary-remains-the-resolver-registry
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate frontend")
}
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend")
if runtime.GOOS == "windows" {
frontend += ".cmd"
}
if _, err := os.Stat(frontend); err != nil {
t.Skip("Flow frontend dependencies are not installed")
}
directory := t.TempDir()
source := filepath.Join(directory, "unknown-resolver.flow.ts")
content := `import { defineFlow } from "@operatorstack/boatstack";
import { softwareDelivery } from "@operatorstack/boatstack-software-delivery";
export default defineFlow(softwareDelivery({
id: "unknown-resolver",
version: "1",
lifecycle: [{ id: "plan.abandon", priority: 31 }],
targets: [{ id: "done", predicate: { true: true } }],
entries: [{ id: "run", target: "done", inputs: [{ id: "value", type: "text", required: true, resolver: "unknown.resolver" }] }],
}));
`
if err := os.WriteFile(source, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
raw, err := exec.Command(frontend, source).CombinedOutput()
if err != nil {
t.Fatalf("helper must emit the unknown reference: %v\n%s", err, raw)
}
resolver, err := softwareflow.NewResolver(context.Background())
if err != nil {
t.Fatal(err)
}
compiled, err := controlprogram.Load(bytes.NewReader(raw), resolver)
if err != nil {
t.Fatal(err)
}
if len(compiled.Document.Declarations.InputResolvers) != 1 || compiled.Document.Declarations.InputResolvers[0] != "unknown.resolver" {
t.Fatalf("helper altered unknown resolver declaration: %#v", compiled.Document.Declarations.InputResolvers)
}
if _, err := softwareflow.PlanInboxForEntry(compiled.Document.Entries[0]); err == nil || !strings.Contains(err.Error(), softwareflow.PlanInboxResolver) {
t.Fatalf("unknown resolver trusted-boundary result = %v", err)
}
}

func TestSoftwareDeliverySugarBindsAdditionalWorkThroughProductionCompiler(t *testing.T) {
// control-law: helper-declared-work-is-explicitly-bound-before-compilation
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate frontend")
}
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend")
if runtime.GOOS == "windows" {
frontend += ".cmd"
}
if _, err := os.Stat(frontend); err != nil {
t.Skip("Flow frontend dependencies are not installed")
}
directory := t.TempDir()
source := filepath.Join(directory, "additional-work.flow.ts")
content := `import { defineFlow, foregroundWork, instructionAsset, workArtifact } from "@operatorstack/boatstack";
import { softwareDelivery } from "@operatorstack/boatstack-software-delivery";
const implementation = foregroundWork({
id: "implementation",
instructions: instructionAsset("implementation.md"),
inputs: [],
outputs: [workArtifact({ id: "result", path: "result.md", media_type: "text/markdown", required: true })],
});
export default defineFlow(softwareDelivery({
id: "additional-work",
version: "1",
lifecycle: [{ id: "plan.activate", priority: 50, work: "implementation" }],
work: [implementation],
targets: [{ id: "done", predicate: { true: true } }],
entries: [{ id: "run", target: "done" }],
}));
`
if err := os.WriteFile(source, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "implementation.md"), []byte("Implement the approved plan.\n"), 0o600); err != nil {
t.Fatal(err)
}
raw, err := exec.Command(frontend, source).CombinedOutput()
if err != nil {
t.Fatalf("compile helper with additional work: %v\n%s", err, raw)
}
resolver, err := softwareflow.NewResolver(context.Background())
if err != nil {
t.Fatal(err)
}
compiled, err := controlprogram.LoadWithAssets(
bytes.NewReader(raw),
resolver,
controlprogram.RepositoryAssetResolver{Repository: directory},
)
if err != nil {
t.Fatalf("load helper with additional work: %v", err)
}
if len(compiled.Document.Work) != 1 || compiled.Document.Work[0].ID != "implementation" {
t.Fatalf("compiled work = %#v", compiled.Document.Work)
}
if len(compiled.Document.Transitions) != 1 || compiled.Document.Transitions[0].Work != "implementation" {
t.Fatalf("compiled transitions = %#v", compiled.Document.Transitions)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
all,
defineFlow,
entry,
entryInput,
fact,
foregroundWork,
instructionAsset,
marked,
schemaAsset,
workArtifact,
} from "@operatorstack/boatstack";
import {
inbox,
planInboxResolver,
planningPackageAdmit,
planningPackageApprove,
planningPackagePromote,
softwareDeliveryEvidence,
softwareDeliveryFacets,
trustedDelegation,
trustedOperators,
trustedSoftwareDeliveryTransitions,
} from "@operatorstack/boatstack-software-delivery";

const planning = foregroundWork({
id: "planning-package",
instructions: instructionAsset("boatstack/testdata/control-programs/assets/planning-package.md"),
inputs: [entryInput("plan")],
outputs: [
workArtifact({ id: "plan", path: "plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }),
workArtifact({ id: "feature-spec", path: "feature-spec.md", media_type: "text/markdown", required: true, max_bytes: 262144 }),
workArtifact({ id: "questions", path: "questions.md", media_type: "text/markdown", required: true, max_bytes: 131072 }),
workArtifact({ id: "test-plan", path: "test-plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }),
workArtifact({ id: "gaps", path: "gaps.md", media_type: "text/markdown", required: false, max_bytes: 131072 }),
workArtifact({ id: "autonomy", path: "autonomy.md", media_type: "text/markdown", required: true, max_bytes: 131072 }),
workArtifact({ id: "tasks", path: "compiled/tasks.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }),
workArtifact({ id: "test-matrix", path: "compiled/test-matrix.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }),
workArtifact({ id: "journey-oracles", path: "compiled/journey-oracles.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }),
workArtifact({ id: "evidence", path: "compiled/evidence.md", media_type: "text/markdown", required: true, max_bytes: 131072 }),
],
});

const lifecycle = [
planningPackageAdmit,
planningPackageApprove,
planningPackagePromote,
{ id: "plan.abandon", priority: 31 },
{ id: "plan.activate", priority: 50 },
{ id: "workspace.cut", priority: 52 },
{ id: "workspace.activate", priority: 53 },
{ id: "workspace.sync", priority: 58 },
{ id: "gate.build.record", priority: 61 },
{ id: "gate.test.record", priority: 62 },
{ id: "gate.review.record", priority: 63 },
{ id: "gate.change.record", priority: 64 },
{ id: "gate.journey.record", priority: 64 },
{ id: "evidence.visual.attach", priority: 66 },
{ id: "delivery.slice.advance", priority: 68 },
{ id: "publication.preview", priority: 72 },
{ id: "workspace.publish", priority: 75 },
{ id: "publication.execute", priority: 76 },
{ id: "publication.observe", priority: 77 },
{ id: "publication.correct", priority: 80 },
{ id: "workspace.reconcile", priority: 2 },
{ id: "publication.reconcile", priority: 1 },
];

export default defineFlow({
id: "product-delivery",
version: "1",
declarations: { input_resolvers: [planInboxResolver] },
facets: softwareDeliveryFacets,
evidence: softwareDeliveryEvidence,
work: [planning],
operators: trustedOperators(lifecycle),
transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }),
targets: [
marked("published-pr", all(
fact("verification", ["current"]),
fact("configuration", ["verified"]),
fact("runtime", ["verified"]),
fact("publication", ["open"]),
)),
marked("safely-abandoned", all(
fact("delivery", ["discarded"]),
fact("workspace", ["abandoned", "absent"]),
)),
],
entries: [
entry({
id: "run",
target: "published-pr",
inputs: [inbox(".boatstack/plans/inbox")],
delegation: trustedDelegation("autonomy"),
}),
entry({
id: "abandon",
target: "safely-abandoned",
inputs: [inbox(".boatstack/plans/inbox")],
}),
],
});
Loading
Loading