diff --git a/.github/tests/test_docs_contract.py b/.github/tests/test_docs_contract.py index 915be5e..01ed556 100644 --- a/.github/tests/test_docs_contract.py +++ b/.github/tests/test_docs_contract.py @@ -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() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29b8438..ded6114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 8dd036a..99bf5f5 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strconv" "strings" @@ -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) @@ -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) + } +} diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts new file mode 100644 index 0000000..74f982e --- /dev/null +++ b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts @@ -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")], + }), + ], +}); diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts index 74f982e..e0d1786 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -12,15 +12,11 @@ import { } from "@operatorstack/boatstack"; import { inbox, - planInboxResolver, planningPackageAdmit, planningPackageApprove, planningPackagePromote, - softwareDeliveryEvidence, - softwareDeliveryFacets, + softwareDelivery, trustedDelegation, - trustedOperators, - trustedSoftwareDeliveryTransitions, } from "@operatorstack/boatstack-software-delivery"; const planning = foregroundWork({ @@ -66,15 +62,11 @@ const lifecycle = [ { id: "publication.reconcile", priority: 1 }, ]; -export default defineFlow({ +export default defineFlow(softwareDelivery({ id: "product-delivery", version: "1", - declarations: { input_resolvers: [planInboxResolver] }, - facets: softwareDeliveryFacets, - evidence: softwareDeliveryEvidence, - work: [planning], - operators: trustedOperators(lifecycle), - transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }), + lifecycle: lifecycle, + planningPackageWork: planning, targets: [ marked("published-pr", all( fact("verification", ["current"]), @@ -100,4 +92,4 @@ export default defineFlow({ inputs: [inbox(".boatstack/plans/inbox")], }), ], -}); +})); diff --git a/docs/getting-started.md b/docs/getting-started.md index 3ccaf48..6184ff4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,6 +27,10 @@ boatstack flow check --repo . boatstack next --repo . --flow product-delivery --entry run --format json ``` +Repository authors can keep software-delivery policy explicit without +repeating its canonical wiring by using the composition shape documented in +[Writing a Flow](product-delivery/writing-a-flow.md). + The first response returns an opaque run ID. Preserve the program, entry, run ID, objective, delivery, authority, and prescription through every subsequent `next`, `apply`, question, and recovery call. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 7bb0562..135fad0 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -17,15 +17,11 @@ import { } from "@operatorstack/boatstack"; import { inbox, - planInboxResolver, planningPackageAdmit, planningPackageApprove, planningPackagePromote, - softwareDeliveryEvidence, - softwareDeliveryFacets, + softwareDelivery, trustedDelegation, - trustedOperators, - trustedSoftwareDeliveryTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -52,17 +48,11 @@ const planning = foregroundWork({ ], }); -export default defineFlow({ +export default defineFlow(softwareDelivery({ id: "product-delivery", version: "1", - declarations: { input_resolvers: [planInboxResolver] }, - facets: softwareDeliveryFacets, - evidence: softwareDeliveryEvidence, - work: [planning], - operators: trustedOperators(lifecycle), - transitions: trustedSoftwareDeliveryTransitions(lifecycle, { - planningPackageWork: planning, - }), + lifecycle: lifecycle, + planningPackageWork: planning, targets: [ marked( "published-pr", @@ -83,9 +73,36 @@ export default defineFlow({ diagnostics: { explain_on_suspend: true }, }), ], -}); +})); ``` +`defineFlow` remains the canonical raw-IR lowering boundary. +`softwareDelivery` is a pure composition helper, not a framework, runtime, or +second Flow language. It executes no repository code and grants no authority. +It adds no lifecycle steps, priorities, targets, entries, or delegation; those +control decisions remain visible in repository source. The low-level exports +remain available for custom facets, evidence, bindings, parameter producers, +authority strengthening, and domain composition. + +| Input | Derived output | +| --- | --- | +| `lifecycle` | trusted operators and transitions, including explicit additional-work bindings | +| `planningPackageWork` | planning work registration and admit binding | +| `work` | additional work registration in caller order; every contract ID must be referenced by a lifecycle step's `work` field | +| entry input `resolver` | `declarations.input_resolvers` | +| software-delivery domain | canonical facets and evidence | +| `targets` | unchanged | +| `entries` | unchanged | + +The resulting committed IR artifact remains inspectable. The helper only +removes coordinated mechanical wiring from the authoring source. + +Additional work remains explicit at both ends. Declare each contract in `work` +and name its ID on every lifecycle step that requires it, for example +`{ id: "plan.activate", priority: 50, work: "implementation" }`. The helper +rejects unknown or unreferenced work IDs; it never chooses a transition for +caller-owned work. + The trusted package owns operator effects, minimum capabilities, trusted authority alternatives, verification, and recovery. The repository owns which trusted transitions are present, their priorities, targets, entries, additional diff --git a/package.json b/package.json index 3436124..fd08c70 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "scripts": { "build:flow-sdk": "tsc -b packages/boatstack packages/boatstack-software-delivery", - "test:flow-sdk": "npm run build:flow-sdk", + "test:flow-sdk": "npm run build:flow-sdk && node --test packages/boatstack-software-delivery/test/*.test.mjs", "docs:build": "npm run build:flow-sdk && typedoc", "docs:check": "npm run docs:build && node --test scripts/check-docs-api.test.mjs && node scripts/check-docs-api.mjs" }, diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 41cb29a..dcd334d 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -20,10 +20,13 @@ import { trustedParameterResolver, transition, type EntryInputDefinition, + type EntryDefinition, type EvidenceDefinition, type FacetDefinition, + type FlowDefinition, type OperatorDefinition, type ParameterProducer, + type TargetDefinition, type TransitionDefinition, type DelegationBindingDefinition, type WorkContract, @@ -84,6 +87,8 @@ export const softwareDeliveryEvidence: EvidenceDefinition[] = [ export interface TrustedStep { id: string; priority: number; + /** Explicit additional work contract ID required by this transition. */ + work?: string; } /** Admits a completed planning package into the delivery lifecycle. */ @@ -102,6 +107,178 @@ export const planningPackagePromote: TrustedStep = { priority: 45, }; +/** + * Repository-owned policy composed with canonical software-delivery wiring. + * + * Lifecycle membership, priorities, work, targets, and entries remain explicit. + * This input contains data only and does not grant authority or execute code. + */ +export interface SoftwareDeliveryFlowDefinition { + /** Stable repository-selected Control Program identity. */ + id: string; + /** Repository-selected Control Program version. */ + version: string; + /** Optional human-readable description passed through unchanged. */ + description?: string; + /** Explicit trusted lifecycle membership and repository-selected priorities. */ + lifecycle: TrustedStep[]; + /** Foreground work bound specifically to `planning.package.admit`. */ + planningPackageWork?: WorkContract; + /** Additional explicit work contracts, in repository-selected order. */ + work?: WorkContract[]; + /** Explicit repository completion predicates. */ + targets: TargetDefinition[]; + /** Explicit repository invocation surfaces. */ + entries: EntryDefinition[]; +} + +function validateSoftwareDeliveryDefinition( + definition: SoftwareDeliveryFlowDefinition, +): void { + const lifecycleIDs = new Set(); + for (const step of definition.lifecycle) { + if (step.id.trim().length === 0) { + throw new Error( + "SOFTWARE_DELIVERY_LIFECYCLE_EMPTY: lifecycle IDs must be non-empty", + ); + } + if (lifecycleIDs.has(step.id)) { + throw new Error( + `SOFTWARE_DELIVERY_LIFECYCLE_DUPLICATE: lifecycle ID ${JSON.stringify(step.id)} appears more than once`, + ); + } + lifecycleIDs.add(step.id); + if (!Number.isSafeInteger(step.priority)) { + throw new Error( + `SOFTWARE_DELIVERY_PRIORITY_INVALID: priority for ${JSON.stringify(step.id)} must be a finite safe integer`, + ); + } + } + + const workIDs = new Set(); + const work = definition.planningPackageWork + ? [definition.planningPackageWork, ...(definition.work ?? [])] + : definition.work ?? []; + for (const contract of work) { + if (workIDs.has(contract.id)) { + throw new Error( + `SOFTWARE_DELIVERY_WORK_DUPLICATE: work ID ${JSON.stringify(contract.id)} appears more than once`, + ); + } + workIDs.add(contract.id); + } + + if ( + definition.planningPackageWork && + !lifecycleIDs.has(planningPackageAdmit.id) + ) { + throw new Error( + "SOFTWARE_DELIVERY_PLANNING_WORK_UNUSED: planningPackageWork requires exactly one planning.package.admit lifecycle step", + ); + } + + const additionalWorkIDs = new Set( + (definition.work ?? []).map((contract) => contract.id), + ); + const referencedWorkIDs = new Set(); + for (const step of definition.lifecycle) { + if (step.work === undefined) { + continue; + } + if (step.work.trim().length === 0) { + throw new Error( + `SOFTWARE_DELIVERY_WORK_REFERENCE_EMPTY: work reference for ${JSON.stringify(step.id)} must be non-empty`, + ); + } + if ( + definition.planningPackageWork && + (step.id === planningPackageAdmit.id || + step.work === definition.planningPackageWork.id) + ) { + throw new Error( + `SOFTWARE_DELIVERY_WORK_CONFLICT: ${JSON.stringify(step.id)} cannot replace or repeat planningPackageWork`, + ); + } + if (!additionalWorkIDs.has(step.work)) { + throw new Error( + `SOFTWARE_DELIVERY_WORK_UNKNOWN: lifecycle step ${JSON.stringify(step.id)} references undeclared work ${JSON.stringify(step.work)}`, + ); + } + referencedWorkIDs.add(step.work); + } + for (const contract of definition.work ?? []) { + if (!referencedWorkIDs.has(contract.id)) { + throw new Error( + `SOFTWARE_DELIVERY_WORK_UNUSED: work ID ${JSON.stringify(contract.id)} is not referenced by a lifecycle step`, + ); + } + } +} + +function referencedInputResolvers(entries: EntryDefinition[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const entry of entries) { + for (const input of entry.inputs ?? []) { + if (!input.resolver || seen.has(input.resolver)) { + continue; + } + seen.add(input.resolver); + result.push(input.resolver); + } + } + return result; +} + +/** + * Composes explicit repository policy with canonical software-delivery wiring. + * + * The returned value is a regular {@link FlowDefinition}; callers still pass it + * to `defineFlow`, the sole raw-IR lowering boundary. This helper selects no + * lifecycle members, priorities, targets, entries, authority, or delegation. + */ +export function softwareDelivery( + definition: SoftwareDeliveryFlowDefinition, +): FlowDefinition { + validateSoftwareDeliveryDefinition(definition); + + const inputResolvers = referencedInputResolvers(definition.entries); + const work = definition.planningPackageWork + ? [definition.planningPackageWork, ...(definition.work ?? [])] + : [...(definition.work ?? [])]; + const transitions = trustedSoftwareDeliveryTransitions(definition.lifecycle, { + planningPackageWork: definition.planningPackageWork, + }).map((transitionDefinition, index) => { + const workID = definition.lifecycle[index].work; + return workID === undefined + ? transitionDefinition + : { ...transitionDefinition, work: workID }; + }); + + return { + id: definition.id, + version: definition.version, + ...(definition.description === undefined + ? {} + : { description: definition.description }), + ...(inputResolvers.length === 0 + ? {} + : { declarations: { input_resolvers: inputResolvers } }), + facets: softwareDeliveryFacets.map((facetDefinition) => ({ + ...facetDefinition, + ...(facetDefinition.values ? { values: [...facetDefinition.values] } : {}), + })), + evidence: softwareDeliveryEvidence.map((evidenceDefinition) => ({ + ...evidenceDefinition, + })), + work, + operators: trustedOperators(definition.lifecycle), + transitions, + targets: [...definition.targets], + entries: [...definition.entries], + }; +} + /** * Repository-owned strengthening applied to a trusted transition. * diff --git a/packages/boatstack-software-delivery/test/software-delivery.test.mjs b/packages/boatstack-software-delivery/test/software-delivery.test.mjs new file mode 100644 index 0000000..830d8a2 --- /dev/null +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + planningPackageAdmit, + softwareDelivery, + softwareDeliveryEvidence, + softwareDeliveryFacets, +} from "../dist/index.js"; + +const target = { id: "done", predicate: { true: true } }; +const entry = { id: "run", target: "done" }; +const work = (id) => ({ + id, + instructions: { path: `${id}.md` }, + inputs: [], + outputs: [], +}); + +function definition(overrides = {}) { + return { + id: "example", + version: "1", + lifecycle: [{ id: "plan.activate", priority: 50 }], + targets: [target], + entries: [entry], + ...overrides, + }; +} + +test("composes canonical domain wiring without hidden policy", () => { + const result = softwareDelivery( + definition({ + description: "Example", + lifecycle: [ + { id: "plan.activate", priority: 50 }, + { id: "plan.abandon", priority: 50 }, + ], + }), + ); + + assert.equal(result.id, "example"); + assert.equal(result.version, "1"); + assert.equal(result.description, "Example"); + assert.deepEqual(result.facets, softwareDeliveryFacets); + assert.deepEqual(result.evidence, softwareDeliveryEvidence); + assert.deepEqual( + result.operators.map(({ id }) => id), + ["plan.activate", "plan.abandon"], + ); + assert.deepEqual( + result.transitions.map(({ id, priority }) => ({ id, priority })), + [ + { id: "plan.activate", priority: 50 }, + { id: "plan.abandon", priority: 50 }, + ], + ); + assert.deepEqual(result.targets, [target]); + assert.deepEqual(result.entries, [entry]); + assert.deepEqual(result.work, []); + assert.equal(result.declarations, undefined); +}); + +test("registers planning work exactly once before additional work", () => { + const planning = work("planning-package"); + const additional = [work("implementation"), work("review")]; + const result = softwareDelivery( + definition({ + lifecycle: [ + planningPackageAdmit, + { id: "plan.activate", priority: 50, work: "implementation" }, + { id: "plan.abandon", priority: 31, work: "review" }, + ], + planningPackageWork: planning, + work: additional, + }), + ); + + assert.deepEqual(result.work.map(({ id }) => id), [ + "planning-package", + "implementation", + "review", + ]); + assert.equal(result.transitions[0].work, "planning-package"); + assert.equal(result.transitions[1].work, "implementation"); + assert.equal(result.transitions[2].work, "review"); +}); + +test("closes resolver declarations over exact first use", () => { + const result = softwareDelivery( + definition({ + entries: [ + { + id: "run", + target: "done", + inputs: [ + { id: "a", type: "text", required: true, resolver: "resolver.b" }, + { id: "b", type: "text", required: true, resolver: "resolver.a" }, + ], + }, + { + id: "resume", + target: "done", + inputs: [ + { id: "c", type: "text", required: true, resolver: "resolver.b" }, + { id: "d", type: "text", required: true }, + ], + }, + ], + }), + ); + + assert.deepEqual(result.declarations, { + input_resolvers: ["resolver.b", "resolver.a"], + }); +}); + +test("planning work alone does not infer an input resolver", () => { + const result = softwareDelivery( + definition({ + lifecycle: [planningPackageAdmit], + planningPackageWork: work("planning-package"), + }), + ); + assert.equal(result.declarations, undefined); +}); + +for (const [name, lifecycle, prefix] of [ + ["blank lifecycle ID", [{ id: " ", priority: 1 }], "SOFTWARE_DELIVERY_LIFECYCLE_EMPTY"], + [ + "duplicate lifecycle ID", + [ + { id: "plan.activate", priority: 1 }, + { id: "plan.activate", priority: 2 }, + ], + "SOFTWARE_DELIVERY_LIFECYCLE_DUPLICATE", + ], + ["fractional priority", [{ id: "plan.activate", priority: 1.5 }], "SOFTWARE_DELIVERY_PRIORITY_INVALID"], + ["NaN priority", [{ id: "plan.activate", priority: Number.NaN }], "SOFTWARE_DELIVERY_PRIORITY_INVALID"], + ["infinite priority", [{ id: "plan.activate", priority: Number.POSITIVE_INFINITY }], "SOFTWARE_DELIVERY_PRIORITY_INVALID"], +]) { + test(`rejects ${name}`, () => { + assert.throws( + () => softwareDelivery(definition({ lifecycle })), + (error) => error instanceof Error && error.message.startsWith(prefix), + ); + }); +} + +test("rejects duplicate work IDs including repeated planning work", () => { + const planning = work("planning-package"); + assert.throws( + () => + softwareDelivery( + definition({ + lifecycle: [planningPackageAdmit], + planningPackageWork: planning, + work: [planning], + }), + ), + /SOFTWARE_DELIVERY_WORK_DUPLICATE/, + ); +}); + +test("rejects planning work without its admit step", () => { + assert.throws( + () => + softwareDelivery( + definition({ planningPackageWork: work("planning-package") }), + ), + /SOFTWARE_DELIVERY_PLANNING_WORK_UNUSED/, + ); +}); + +test("rejects additional work that is not bound to a lifecycle step", () => { + assert.throws( + () => softwareDelivery(definition({ work: [work("implementation")] })), + /SOFTWARE_DELIVERY_WORK_UNUSED/, + ); +}); + +test("rejects lifecycle references to undeclared work", () => { + assert.throws( + () => + softwareDelivery( + definition({ + lifecycle: [ + { id: "plan.activate", priority: 50, work: "implementation" }, + ], + }), + ), + /SOFTWARE_DELIVERY_WORK_UNKNOWN/, + ); +}); + +test("rejects empty lifecycle work references", () => { + assert.throws( + () => + softwareDelivery( + definition({ + lifecycle: [{ id: "plan.activate", priority: 50, work: " " }], + }), + ), + /SOFTWARE_DELIVERY_WORK_REFERENCE_EMPTY/, + ); +}); + +test("rejects replacing or repeating the planning work binding", () => { + const planning = work("planning-package"); + for (const lifecycle of [ + [{ ...planningPackageAdmit, work: "implementation" }], + [planningPackageAdmit, { id: "plan.activate", priority: 50, work: planning.id }], + ]) { + assert.throws( + () => + softwareDelivery( + definition({ + lifecycle, + planningPackageWork: planning, + work: [work("implementation")], + }), + ), + /SOFTWARE_DELIVERY_WORK_CONFLICT/, + ); + } +}); + +test("does not mutate inputs or expose mutable canonical arrays", () => { + const lifecycle = Object.freeze([{ id: "plan.activate", priority: 50 }]); + const targets = Object.freeze([target]); + const entries = Object.freeze([entry]); + const input = Object.freeze({ + id: "example", + version: "1", + lifecycle, + targets, + entries, + }); + const canonicalFacetID = softwareDeliveryFacets[0].id; + const canonicalEvidenceID = softwareDeliveryEvidence[0].id; + + const result = softwareDelivery(input); + result.facets[0].id = "changed"; + result.evidence[0].id = "changed"; + result.targets.push({ id: "other", predicate: { true: true } }); + result.entries.push({ id: "other", target: "done" }); + + assert.equal(softwareDeliveryFacets[0].id, canonicalFacetID); + assert.equal(softwareDeliveryEvidence[0].id, canonicalEvidenceID); + assert.deepEqual(targets, [target]); + assert.deepEqual(entries, [entry]); + assert.deepEqual(lifecycle, [{ id: "plan.activate", priority: 50 }]); +}); diff --git a/release-notes/2026-08-17-software-delivery-flow-composition.md b/release-notes/2026-08-17-software-delivery-flow-composition.md new file mode 100644 index 0000000..eab899c --- /dev/null +++ b/release-notes/2026-08-17-software-delivery-flow-composition.md @@ -0,0 +1,3 @@ +### Compose software-delivery Flows without repeated wiring + +Repositories can use the new `softwareDelivery` helper to derive canonical domain wiring while keeping lifecycle steps, priorities, work, targets, entries, authority, and delegation explicit. Additional work is bound by contract ID on the repository-selected lifecycle step, and unknown or unreferenced contracts fail before compilation. diff --git a/scripts/check-docs-api.mjs b/scripts/check-docs-api.mjs index 9f3fd6f..87da9e7 100644 --- a/scripts/check-docs-api.mjs +++ b/scripts/check-docs-api.mjs @@ -5,7 +5,7 @@ const required = new Map([ ["@operatorstack/boatstack", ["defineFlow", "entry", "marked"]], [ "@operatorstack/boatstack-software-delivery", - ["inbox", "trustedDelegation", "trustedTransition"], + ["inbox", "softwareDelivery", "trustedDelegation", "trustedTransition"], ], ]); diff --git a/scripts/check-docs-api.test.mjs b/scripts/check-docs-api.test.mjs index ca663c2..650e0ad 100644 --- a/scripts/check-docs-api.test.mjs +++ b/scripts/check-docs-api.test.mjs @@ -7,6 +7,7 @@ const requiredFunctions = { "@operatorstack/boatstack": ["defineFlow", "entry", "marked"], "@operatorstack/boatstack-software-delivery": [ "inbox", + "softwareDelivery", "trustedDelegation", "trustedTransition", ],