From ab3e13a221833a32a175271fb446a18518db84ab Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 04:58:52 +0100 Subject: [PATCH 1/4] docs: publish TypeScript SDK reference --- .github/tests/test_docs_contract.py | 42 +++ .github/workflows/ci.yml | 4 +- .github/workflows/docs-pages.yml | 42 +++ .gitignore | 1 + README.md | 7 + .../authority-and-delegation.md | 26 ++ docs/product-delivery/diagnostics.md | 19 ++ .../documentation-architecture.md | 21 ++ docs/product-delivery/index.md | 27 ++ docs/product-delivery/targets-and-entries.md | 22 ++ docs/product-delivery/writing-a-flow.md | 62 +++++ package-lock.json | 259 ++++++++++++++++++ package.json | 5 +- .../boatstack-software-delivery/src/index.ts | 57 ++++ packages/boatstack/src/index.ts | 108 ++++++++ ...2026-08-15-typescript-sdk-documentation.md | 3 + scripts/check-docs-api.mjs | 45 +++ typedoc.json | 41 +++ 18 files changed, 788 insertions(+), 3 deletions(-) create mode 100644 .github/tests/test_docs_contract.py create mode 100644 .github/workflows/docs-pages.yml create mode 100644 docs/product-delivery/authority-and-delegation.md create mode 100644 docs/product-delivery/diagnostics.md create mode 100644 docs/product-delivery/documentation-architecture.md create mode 100644 docs/product-delivery/index.md create mode 100644 docs/product-delivery/targets-and-entries.md create mode 100644 docs/product-delivery/writing-a-flow.md create mode 100644 release-notes/2026-08-15-typescript-sdk-documentation.md create mode 100644 scripts/check-docs-api.mjs create mode 100644 typedoc.json diff --git a/.github/tests/test_docs_contract.py b/.github/tests/test_docs_contract.py new file mode 100644 index 0000000..1f358c4 --- /dev/null +++ b/.github/tests/test_docs_contract.py @@ -0,0 +1,42 @@ +import json +import unittest +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] + + +class DocumentationContractTests(unittest.TestCase): + def test_required_ci_validates_documentation(self) -> None: + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() + self.assertIn("npm run docs:check", ci) + + def test_pages_deployment_is_main_only_and_release_independent(self) -> None: + workflow = (REPO / ".github" / "workflows" / "docs-pages.yml").read_text() + self.assertIn("branches: [main]", workflow) + self.assertNotIn("pull_request:", workflow) + self.assertIn("actions/configure-pages@v6", workflow) + self.assertIn("actions/upload-pages-artifact@v5", workflow) + self.assertIn("actions/deploy-pages@v5", workflow) + self.assertIn("pages: write", workflow) + self.assertIn("id-token: write", workflow) + self.assertNotIn("release.yml", workflow) + self.assertNotIn("gh release", workflow) + + def test_typedoc_covers_both_public_packages(self) -> None: + config = json.loads((REPO / "typedoc.json").read_text()) + self.assertEqual(config["entryPointStrategy"], "packages") + self.assertEqual( + set(config["entryPoints"]), + { + "packages/boatstack", + "packages/boatstack-software-delivery", + }, + ) + self.assertEqual(config["out"], "build/docs/html") + self.assertEqual(config["json"], "build/docs/api.json") + self.assertTrue(config["treatWarningsAsErrors"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd15f18..87820d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,8 @@ jobs: with: go-version-file: boatstack/go.mod cache-dependency-path: boatstack/go.sum - - name: Build TypeScript frontends - run: npm ci && npm run build:flow-sdk + - name: Build TypeScript frontends and documentation + run: npm ci && npm run docs:check - name: Prove frontend canonical equivalence working-directory: boatstack env: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..f6b4e4a --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,42 @@ +name: Deploy TypeScript SDK documentation + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: github-pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run docs:build + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: build/docs/html + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 987f1d9..215b19e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ boatstack/boatstack-helper boatstack/boatstack-helper.exe dist/ +build/docs/ node_modules/ *.tsbuildinfo .DS_Store diff --git a/README.md b/README.md index 4ff42b8..459ebe2 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,13 @@ go build ./... Every pull request that changes Boatstack adds an append-only release note. See [CONTRIBUTING.md](CONTRIBUTING.md). +### TypeScript SDK documentation + +The [TypeScript Flow authoring reference](https://operatorstack.github.io/boatstack/) +documents both `@operatorstack/boatstack` and +`@operatorstack/boatstack-software-delivery`. Build the same site locally with +`npm run docs:build`, then open `build/docs/html/index.html`. + ## Status Boatstack is being built in public and is not ready to promise compatibility. diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md new file mode 100644 index 0000000..2f930c0 --- /dev/null +++ b/docs/product-delivery/authority-and-delegation.md @@ -0,0 +1,26 @@ +# Authority and delegation + +Trusted operators keep alternatives and mandatory requirements separate: + +```text +any_of: human | autonomy +all_of: external-provider +``` + +Runtime admission requires one trusted alternative, every trusted mandatory +authority, and every repository-added authority. A repository may strengthen a +transition with `requires.authorities`, but it cannot weaken trusted authority, +replace provider authority, or grant authority. + +```ts +trustedTransition( + { id: "publication.execute", priority: 76 }, + { requires: { authorities: ["human"] } }, +); +``` + +`trustedDelegation("autonomy")` requests a trusted delegation mechanism for an +entry. It does not authorize the run. Boatstack presents an exact run-bound +request at runtime, and only a trusted human/host boundary can authorize it. +Revocation, expiry, incompatible drift, or an unauthorized execution context +ends or suspends that delegation. External-provider authority remains separate. diff --git a/docs/product-delivery/diagnostics.md b/docs/product-delivery/diagnostics.md new file mode 100644 index 0000000..67c18a7 --- /dev/null +++ b/docs/product-delivery/diagnostics.md @@ -0,0 +1,19 @@ +# Diagnostics and `boatstack explain` + +An entry can request an explanation when execution suspends: + +```ts +entry({ + id: "run", + target: "published-pr", + diagnostics: { explain_on_suspend: true }, +}); +``` + +This option changes generated-agent UX. It is bound into the generated artifact +and skills, but it is excluded from the executable Control Program fingerprint. +It cannot make a transition admissible or provide authority. + +`boatstack explain` is read-only. It reports the current decision trace, +including candidates and rejection reasons. Treat that explanation as evidence +for deciding the next action, never as permission to perform an effect. diff --git a/docs/product-delivery/documentation-architecture.md b/docs/product-delivery/documentation-architecture.md new file mode 100644 index 0000000..78dcee6 --- /dev/null +++ b/docs/product-delivery/documentation-architecture.md @@ -0,0 +1,21 @@ +# Documentation architecture + +This site has two deliberately separate future-facing inputs: + +```text +TypeDoc + documents APIs available to Flow authors + +future Flow renderer + documents a concrete control system built with those APIs +``` + +TypeDoc reads the authoritative TypeScript declarations and comments. Its JSON +reflection model is retained at `build/docs/api.json` as a possible input for +future cross-linking. + +A later renderer may read canonical `.flow.ir.json` and describe entries, +targets, transitions, authority, delegation, and diagnostics for a concrete +repository Flow. It must consume canonical IR rather than interpreting +TypeScript source, and it must not become a second source of executable +semantics. diff --git a/docs/product-delivery/index.md b/docs/product-delivery/index.md new file mode 100644 index 0000000..72e3454 --- /dev/null +++ b/docs/product-delivery/index.md @@ -0,0 +1,27 @@ +# Product Delivery Flow authoring + +Boatstack repositories own their delivery control law. The TypeScript SDK is an +authoring frontend that produces declarative Control Program IR; TypeScript is +not executed by the Boatstack runtime. + +```text +Flow TypeScript + ↓ +canonical Control Program IR + ↓ +trusted software-delivery bindings + ↓ +Boatstack runtime +``` + +Use the API reference for exact signatures and these guides for the control +semantics behind those signatures: + +- [Writing a Flow](writing-a-flow.md) +- [Targets and entries](targets-and-entries.md) +- [Authority and delegation](authority-and-delegation.md) +- [Diagnostics and `boatstack explain`](diagnostics.md) +- [Documentation architecture](documentation-architecture.md) + +The internal runtime model is documented separately in the repository's +[Control Program IR specification](https://github.com/operatorstack/boatstack/blob/main/docs/control-program-ir.md). diff --git a/docs/product-delivery/targets-and-entries.md b/docs/product-delivery/targets-and-entries.md new file mode 100644 index 0000000..7f20ad8 --- /dev/null +++ b/docs/product-delivery/targets-and-entries.md @@ -0,0 +1,22 @@ +# Targets and entries + +The three parts of a Flow answer different questions: + +```text +transitions what may happen +targets what counts as done +entries which target this invocation pursues +``` + +A Flow may contain several marked targets, such as `published-pr` and +`safely-abandoned`. Each entry names exactly one target. Boatstack carries that +program-scoped target through resolution, prescriptions, receipts, recovery, +and replay. + +An entry may also declare typed inputs. The software-delivery `inbox` helper +requires exactly one eligible Markdown plan. Input resolution happens before a +managed run is created; zero or multiple plans stop with a typed blocker. + +Targets are predicates over declared facets. They do not select a hard-coded +Boatstack mode. The supervisor chooses admissible, target-coreachable +transitions from the repository's declared relation. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md new file mode 100644 index 0000000..06553c1 --- /dev/null +++ b/docs/product-delivery/writing-a-flow.md @@ -0,0 +1,62 @@ +# Writing a Flow + +A repository Flow chooses trusted operations, their priorities, terminal +targets, and named entries. It does not provide executable operator handlers. + +```ts +import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { + inbox, + planInboxResolver, + softwareDeliveryEvidence, + softwareDeliveryFacets, + trustedDelegation, + trustedOperators, + trustedTransitions, + type TrustedStep, +} from "@operatorstack/boatstack-software-delivery"; + +const lifecycle = [ + { id: "publication.observe", priority: 77 }, +] satisfies TrustedStep[]; + +export default defineFlow({ + id: "product-delivery", + version: "1", + declarations: { input_resolvers: [planInboxResolver] }, + facets: softwareDeliveryFacets, + evidence: softwareDeliveryEvidence, + operators: trustedOperators(lifecycle), + transitions: trustedTransitions(lifecycle), + targets: [ + marked( + "published-pr", + all( + fact("verification", ["current"]), + fact("configuration", ["verified"]), + fact("runtime", ["verified"]), + fact("publication", ["open"]), + ), + ), + ], + entries: [ + entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + delegation: trustedDelegation("autonomy"), + diagnostics: { explain_on_suspend: true }, + }), + ], +}); +``` + +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 +mandatory authority, and diagnostics UX. + +Compilation lowers this source to raw IR. Boatstack then validates references, +resolves trusted bindings, canonicalizes executable semantics, and fingerprints +the program. Runtime commands load the committed IR artifact; they do not +execute this source file. diff --git a/package-lock.json b/package-lock.json index f4ec294..5071957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,24 @@ "packages/*" ], "devDependencies": { + "typedoc": "0.28.20", "typescript": "5.9.3" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@operatorstack/boatstack": { "resolved": "packages/boatstack", "link": true @@ -20,6 +35,227 @@ "resolved": "packages/boatstack-software-delivery", "link": true }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -33,6 +269,29 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "packages/boatstack": { "name": "@operatorstack/boatstack", "version": "0.0.0-development", diff --git a/package.json b/package.json index cbd5aa4..4db0531 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ ], "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", + "docs:build": "npm run build:flow-sdk && typedoc", + "docs:check": "npm run docs:build && node scripts/check-docs-api.mjs" }, "devDependencies": { + "typedoc": "0.28.20", "typescript": "5.9.3" } } diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index fe35c77..6c838e4 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -1,3 +1,14 @@ +/** + * Trusted software-delivery bindings for repository-owned Boatstack Flows. + * + * Repositories choose lifecycle membership, priorities, targets, entries, and + * additional mandatory authority. Boatstack's trusted binding registry owns + * effects, handlers, minimum capability, authority, verification, and recovery + * semantics. + * + * @packageDocumentation + */ + import { always, facet, @@ -12,8 +23,10 @@ import { } from "@operatorstack/boatstack"; const bindingPrefix = "software-delivery/"; +/** Trusted resolver reference used by {@link inbox}. */ export const planInboxResolver = "software-delivery.plan-inbox"; +/** State facets declared by the trusted software-delivery domain adapter. */ export const softwareDeliveryFacets: FacetDefinition[] = [ "phase", "program", @@ -47,6 +60,7 @@ export const softwareDeliveryFacets: FacetDefinition[] = [ "worktree_fingerprint", ].map((id) => facet(id, "string")); +/** Evidence relations declared by the software-delivery domain adapter. */ export const softwareDeliveryEvidence: EvidenceDefinition[] = [ { id: "plan-evidence", subject: "plan", kind: "artifact" }, { @@ -56,15 +70,38 @@ export const softwareDeliveryEvidence: EvidenceDefinition[] = [ }, ]; +/** Selects a trusted software-delivery operation and its repository priority. */ export interface TrustedStep { id: string; priority: number; } +/** + * Repository-owned strengthening applied to a trusted transition. + * + * Authorities listed here are additional mandatory requirements. They cannot + * replace trusted alternatives, weaken provider requirements, or grant + * authority. + */ export interface TrustedTransitionOptions { requires?: { authorities?: string[] }; } +/** + * Requests trusted run-scoped autonomy delegation for an entry. + * + * This declaration does not grant authority. Boatstack materializes authority + * only from a runtime-owned authorization bound to the exact run. + * + * @example + * ```ts + * entry({ + * id: "run", + * target: "published-pr", + * delegation: trustedDelegation("autonomy"), + * }) + * ``` + */ export function trustedDelegation( authority: "autonomy", ): DelegationBindingDefinition { @@ -74,6 +111,12 @@ export function trustedDelegation( }; } +/** + * Requires exactly one regular Markdown plan from a repository inbox. + * + * Input selection happens before managed run state is created. Zero or several + * eligible plans produce a typed blocker rather than an arbitrary choice. + */ export function inbox(path: string): EntryInputDefinition { return { id: "plan", @@ -84,16 +127,24 @@ export function inbox(path: string): EntryInputDefinition { }; } +/** Resolves one lifecycle step through the trusted operator registry. */ export function trustedOperator(step: TrustedStep): OperatorDefinition { return operator(step.id, { binding: { reference: `${bindingPrefix}${step.id}`, version: "1" }, }); } +/** Resolves a lifecycle list through the trusted operator registry. */ export function trustedOperators(steps: TrustedStep[]): OperatorDefinition[] { return steps.map(trustedOperator); } +/** + * Declares one repository-selected transition backed by a trusted operation. + * + * The repository may set priority and add mandatory authority through + * `options.requires`. It cannot override the trusted effect or handler. + */ export function trustedTransition( step: TrustedStep, options: TrustedTransitionOptions = {}, @@ -106,6 +157,12 @@ export function trustedTransition( }); } +/** + * Declares trusted transitions for a lifecycle list. + * + * Pass the same lifecycle used by {@link trustedOperators}; compilation rejects + * unresolved operator references, but authors should keep this pairing explicit. + */ export function trustedTransitions( steps: TrustedStep[], options: TrustedTransitionOptions = {}, diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index e1367db..ee3f636 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -1,6 +1,24 @@ +/** + * Domain-neutral primitives for authoring Boatstack Control Programs. + * + * The TypeScript API creates declarative IR. It does not execute operators or + * grant authority; the Boatstack runtime validates and executes the compiled + * program. + * + * @packageDocumentation + */ + +/** Canonical schema name emitted by {@link defineFlow}. */ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; +/** Current revision of the canonical Control Program schema. */ export const CONTROL_PROGRAM_SCHEMA_REVISION = 2 as const; +/** + * A declarative condition over runtime state facts. + * + * Predicates influence executable transition and target semantics. A frontend + * author may combine facts, but only the runtime decides whether they hold. + */ export type Predicate = | { true: boolean } | { fact: { facet: string; statuses?: string[]; values?: string[] } } @@ -8,6 +26,7 @@ export type Predicate = | { any: Predicate[] } | { not: Predicate }; +/** Declares one typed state facet owned by a Control Program. */ export interface FacetDefinition { id: string; kind: "enum" | "string" | "boolean"; @@ -15,6 +34,7 @@ export interface FacetDefinition { description?: string; } +/** Declares a kind of evidence that a program may reference. */ export interface EvidenceDefinition { id: string; subject: string; @@ -22,23 +42,32 @@ export interface EvidenceDefinition { description?: string; } +/** Requires a facet to hold one of the listed values before an effect runs. */ export interface StatePrecondition { facet: string; values: string[]; } +/** Selects a state value from trusted runtime input instead of a literal. */ export interface StateValueReference { parameter?: string; admission?: string; invocation?: string; } +/** Assigns one declared facet from a literal or trusted runtime value. */ export interface StateAssignment { facet: string; value?: string; value_from?: StateValueReference; } +/** + * Declares the state mutation associated with an operator. + * + * Native handlers are references resolved by the trusted runtime. Repository + * source cannot provide executable handler code through this type. + */ export type StateEffectDefinition = | { kind: "assignments"; @@ -51,6 +80,13 @@ export type StateEffectDefinition = native_handler: string; }; +/** + * Declares an operator and its trusted execution contract. + * + * Capabilities, authority, effects, verification, recovery, and execution + * context are executable control semantics. Domain adapters should normally + * supply trusted bindings instead of asking repository Flows to repeat them. + */ export interface OperatorDefinition { id: string; binding?: { reference: string; version: string }; @@ -64,6 +100,11 @@ export interface OperatorDefinition { description?: string; } +/** + * Selects an operator when its guard is true and records its intended target + * relation. Higher priority controls deterministic selection among admissible + * transitions. + */ export interface TransitionDefinition { id: string; operator: string; @@ -74,12 +115,14 @@ export interface TransitionDefinition { description?: string; } +/** A named marked condition that counts as completion for an entry. */ export interface TargetDefinition { id: string; predicate: Predicate; description?: string; } +/** Declares one typed value that must be resolved before a run starts. */ export interface EntryInputDefinition { id: string; type: string; @@ -88,6 +131,13 @@ export interface EntryInputDefinition { config?: unknown; } +/** + * A named invocation surface that selects a target and its required inputs. + * + * Delegation requests a trusted runtime mechanism; it never grants authority. + * Diagnostics affect generated-agent projection only and do not change the + * executable Control Program fingerprint. + */ export interface EntryDefinition { id: string; target: string; @@ -97,11 +147,13 @@ export interface EntryDefinition { description?: string; } +/** References a trusted delegation implementation resolved by Boatstack. */ export interface DelegationBindingDefinition { reference: string; version: string; } +/** Complete domain-neutral authoring input accepted by {@link defineFlow}. */ export interface FlowDefinition { id: string; version: string; @@ -121,6 +173,7 @@ export interface FlowDefinition { entries: EntryDefinition[]; } +/** Canonical raw IR emitted by the TypeScript authoring frontend. */ export interface ControlProgramIR { schema: typeof CONTROL_PROGRAM_SCHEMA; schema_revision: typeof CONTROL_PROGRAM_SCHEMA_REVISION; @@ -135,6 +188,25 @@ export interface ControlProgramIR { description?: string; } +/** + * Lowers a declarative Flow definition to raw Control Program IR. + * + * Boatstack still canonicalizes, validates, resolves trusted bindings, and + * fingerprints the result. Calling this function does not execute the Flow. + * + * @example + * ```ts + * const flow = defineFlow({ + * id: "example", + * version: "1", + * facets: [facet("status", "enum", ["open", "done"])], + * operators: [], + * transitions: [], + * targets: [marked("done", fact("status", ["done"]))], + * entries: [entry({ id: "run", target: "done" })], + * }); + * ``` + */ export function defineFlow(definition: FlowDefinition): ControlProgramIR { return { schema: CONTROL_PROGRAM_SCHEMA, @@ -157,6 +229,12 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { }; } +/** + * Declares a typed state facet. + * + * Facet identifiers and allowed values are executable schema. The runtime + * rejects effects and predicates that reference undeclared facets or values. + */ export function facet( id: string, kind: FacetDefinition["kind"], @@ -165,6 +243,7 @@ export function facet( return { id, kind, ...(values ? { values } : {}) }; } +/** Declares an evidence relation without creating evidence at authoring time. */ export function evidence( id: string, subject: string, @@ -173,6 +252,12 @@ export function evidence( return { id, subject, kind }; } +/** + * Declares a generic operator. + * + * Prefer a trusted domain adapter where one exists. This helper describes an + * operator but cannot make an untrusted implementation executable. + */ export function operator( id: string, definition: Omit, @@ -180,6 +265,7 @@ export function operator( return { id, ...definition }; } +/** Declares a generic transition that refers to a declared operator. */ export function transition( id: string, operatorID: string, @@ -188,6 +274,12 @@ export function transition( return { id, operator: operatorID, ...definition }; } +/** + * Declares a named terminal target. + * + * Targets define what counts as done. Entries select which target a particular + * invocation pursues. + */ export function marked( id: string, predicate: Predicate, @@ -196,12 +288,27 @@ export function marked( return { id, predicate, ...(description ? { description } : {}) }; } +/** + * Declares an invocation entry and normalizes omitted inputs to an empty list. + * + * @example + * ```ts + * entry({ id: "run", target: "published-pr" }) + * ``` + */ export function entry(definition: EntryDefinition): EntryDefinition { return { ...definition, inputs: definition.inputs ?? [] }; } +/** Predicate that is true in every state. */ export const always: Predicate = { true: true }; +/** + * Matches a facet value with optional evidence-status constraints. + * + * The default status is `known`; pass an explicit list when unknown or other + * statuses are admissible. + */ export function fact( facetID: string, values: string[] = [], @@ -210,6 +317,7 @@ export function fact( return { fact: { facet: facetID, statuses, values } }; } +/** Requires every supplied predicate to hold. */ export function all(...predicates: Predicate[]): Predicate { return { all: predicates }; } diff --git a/release-notes/2026-08-15-typescript-sdk-documentation.md b/release-notes/2026-08-15-typescript-sdk-documentation.md new file mode 100644 index 0000000..ee74319 --- /dev/null +++ b/release-notes/2026-08-15-typescript-sdk-documentation.md @@ -0,0 +1,3 @@ +### Publish TypeScript SDK documentation + +Flow authors can now browse generated API reference and Product Delivery guides, while required CI validates the documentation before SDK changes merge. diff --git a/scripts/check-docs-api.mjs b/scripts/check-docs-api.mjs new file mode 100644 index 0000000..2f5ab63 --- /dev/null +++ b/scripts/check-docs-api.mjs @@ -0,0 +1,45 @@ +import { readFile } from "node:fs/promises"; + +const model = JSON.parse(await readFile("build/docs/api.json", "utf8")); +const required = new Map([ + ["@operatorstack/boatstack", ["defineFlow", "entry", "marked"]], + [ + "@operatorstack/boatstack-software-delivery", + ["inbox", "trustedDelegation", "trustedTransition"], + ], +]); + +function reflectionNames(node, names = new Set()) { + if (Array.isArray(node)) { + for (const value of node) reflectionNames(value, names); + } else if (node && typeof node === "object") { + if (typeof node.name === "string") names.add(node.name); + for (const value of Object.values(node)) reflectionNames(value, names); + } + return names; +} + +const packages = new Map( + (model.children ?? []).map((child) => [child.name, reflectionNames(child)]), +); +const failures = []; + +for (const [packageName, exports] of required) { + const names = packages.get(packageName); + if (!names) { + failures.push(`missing documented package ${packageName}`); + continue; + } + for (const exportName of exports) { + if (!names.has(exportName)) { + failures.push(`missing documented export ${packageName}.${exportName}`); + } + } +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); +} + +console.log("required public TypeScript SDK exports are documented"); diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..d73fa4f --- /dev/null +++ b/typedoc.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "name": "Boatstack TypeScript SDK", + "entryPoints": [ + "packages/boatstack", + "packages/boatstack-software-delivery" + ], + "entryPointStrategy": "packages", + "packageOptions": { + "entryPoints": ["src/index.ts"], + "entryPointStrategy": "resolve", + "excludeInternal": true, + "readme": "none", + "validation": { + "invalidLink": true, + "notExported": true, + "notDocumented": true + }, + "requiredToBeDocumented": ["Function", "Interface", "TypeAlias", "Variable"] + }, + "readme": "docs/product-delivery/index.md", + "projectDocuments": [ + "docs/product-delivery/writing-a-flow.md", + "docs/product-delivery/targets-and-entries.md", + "docs/product-delivery/authority-and-delegation.md", + "docs/product-delivery/diagnostics.md", + "docs/product-delivery/documentation-architecture.md" + ], + "out": "build/docs/html", + "json": "build/docs/api.json", + "cleanOutputDir": true, + "githubPages": true, + "includeVersion": false, + "searchInComments": true, + "searchInDocuments": true, + "treatWarningsAsErrors": true, + "treatValidationWarningsAsErrors": true, + "navigationLinks": { + "Repository": "https://github.com/operatorstack/boatstack" + } +} From e597914d6d34755978f5fc65400a3d8acc2639fb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 05:09:26 +0100 Subject: [PATCH 2/4] test: ignore transient git internals in snapshots --- .../cmd/boatstack-helper/flow_runtime_test.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 1fcac2f..d6eebdd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1023,13 +1023,16 @@ func repositoryBytes(t *testing.T, root string) map[string]string { if err != nil { return err } - if entry.IsDir() { - return nil - } relative, err := filepath.Rel(root, path) if err != nil { return err } + if entry.IsDir() && relative == ".git" { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } raw, err := os.ReadFile(path) if err != nil { return err @@ -1043,6 +1046,21 @@ func repositoryBytes(t *testing.T, root string) map[string]string { return result } +func TestRepositoryBytesExcludesGitInternals(t *testing.T) { + repository := t.TempDir() + writeFixture(t, repository, ".git/objects/maintenance.lock", []byte("transient")) + writeFixture(t, repository, ".boatstack/controller.json", []byte("managed")) + writeFixture(t, repository, "README.md", []byte("repository")) + + snapshot := repositoryBytes(t, repository) + if _, exists := snapshot[".git/objects/maintenance.lock"]; exists { + t.Fatal("repository snapshot included transient Git internals") + } + if snapshot[".boatstack/controller.json"] != "managed" || snapshot["README.md"] != "repository" { + t.Fatalf("repository snapshot omitted managed or ordinary files: %#v", snapshot) + } +} + func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { // control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters repository := flowRepository(t) From 2541dde16fe2482871309f64aa82d55c1d370651 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 05:24:00 +0100 Subject: [PATCH 3/4] fix: enforce documentation publication contracts --- .github/tests/test_docs_contract.py | 52 +++++++++++++++- .github/workflows/docs-pages.yml | 4 +- .../cmd/boatstack-helper/flow_runtime_test.go | 33 ++++++++++ package.json | 2 +- scripts/check-docs-api.mjs | 62 ++++++++++--------- scripts/check-docs-api.test.mjs | 49 +++++++++++++++ 6 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 scripts/check-docs-api.test.mjs diff --git a/.github/tests/test_docs_contract.py b/.github/tests/test_docs_contract.py index 1f358c4..62c4d37 100644 --- a/.github/tests/test_docs_contract.py +++ b/.github/tests/test_docs_contract.py @@ -6,6 +6,36 @@ REPO = Path(__file__).resolve().parents[2] +def workflow_triggers(workflow: str) -> dict[str, dict[str, list[str]]]: + lines = workflow.splitlines() + try: + start = lines.index("on:") + 1 + except ValueError as error: + raise AssertionError("workflow has no top-level on mapping") from error + + triggers: dict[str, dict[str, list[str]]] = {} + current: str | None = None + for line in lines[start:]: + content = line.split("#", 1)[0].rstrip() + if not content.strip(): + continue + indent = len(content) - len(content.lstrip()) + if indent == 0: + break + stripped = content.strip() + if indent == 2 and stripped.endswith(":"): + current = stripped[:-1] + triggers[current] = {} + continue + if indent == 4 and current and ":" in stripped: + key, value = (part.strip() for part in stripped.split(":", 1)) + if value.startswith("[") and value.endswith("]"): + triggers[current][key] = [ + item.strip() for item in value[1:-1].split(",") if item.strip() + ] + return triggers + + class DocumentationContractTests(unittest.TestCase): def test_required_ci_validates_documentation(self) -> None: ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() @@ -13,8 +43,12 @@ def test_required_ci_validates_documentation(self) -> None: def test_pages_deployment_is_main_only_and_release_independent(self) -> None: workflow = (REPO / ".github" / "workflows" / "docs-pages.yml").read_text() - self.assertIn("branches: [main]", workflow) - self.assertNotIn("pull_request:", workflow) + self.assertEqual( + workflow_triggers(workflow), + {"push": {"branches": ["main"]}, "workflow_dispatch": {}}, + ) + self.assertEqual(workflow.count("if: github.ref == 'refs/heads/main'"), 2) + self.assertIn("npm run docs:check", workflow) self.assertIn("actions/configure-pages@v6", workflow) self.assertIn("actions/upload-pages-artifact@v5", workflow) self.assertIn("actions/deploy-pages@v5", workflow) @@ -23,6 +57,20 @@ def test_pages_deployment_is_main_only_and_release_independent(self) -> None: self.assertNotIn("release.yml", workflow) self.assertNotIn("gh release", workflow) + def test_trigger_parser_rejects_comment_substitution(self) -> None: + malformed = """on: + push: + # branches: [main] + pull_request: + workflow_dispatch: + +jobs: {} +""" + self.assertEqual( + workflow_triggers(malformed), + {"push": {}, "pull_request": {}, "workflow_dispatch": {}}, + ) + def test_typedoc_covers_both_public_packages(self) -> None: config = json.loads((REPO / "typedoc.json").read_text()) self.assertEqual(config["entryPointStrategy"], "packages") diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index f6b4e4a..65ec709 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -11,6 +11,7 @@ concurrency: jobs: build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read @@ -21,13 +22,14 @@ jobs: node-version: '20' cache: npm - run: npm ci - - run: npm run docs:build + - run: npm run docs:check - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: path: build/docs/html deploy: + if: github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest permissions: diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index d6eebdd..369942b 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -52,6 +52,16 @@ func runFlowGit(t *testing.T, repository string, arguments ...string) { } } +func runFlowGitOutput(t *testing.T, repository string, arguments ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } + return strings.TrimSpace(string(output)) +} + func captureRunOutput(t *testing.T, arguments ...string) ([]byte, error) { t.Helper() return captureStdout(t, func() error { return run(arguments) }) @@ -1043,14 +1053,19 @@ func repositoryBytes(t *testing.T, root string) map[string]string { if err != nil { t.Fatal(err) } + result[".git/@semantic/HEAD"] = runFlowGitOutput(t, root, "rev-parse", "--verify", "HEAD") + result[".git/@semantic/refs"] = runFlowGitOutput(t, root, "for-each-ref", "--format=%(refname)%09%(objectname)") return result } func TestRepositoryBytesExcludesGitInternals(t *testing.T) { repository := t.TempDir() + runFlowGit(t, repository, "init") writeFixture(t, repository, ".git/objects/maintenance.lock", []byte("transient")) writeFixture(t, repository, ".boatstack/controller.json", []byte("managed")) writeFixture(t, repository, "README.md", []byte("repository")) + runFlowGit(t, repository, "add", ".boatstack/controller.json", "README.md") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") snapshot := repositoryBytes(t, repository) if _, exists := snapshot[".git/objects/maintenance.lock"]; exists { @@ -1059,6 +1074,24 @@ func TestRepositoryBytesExcludesGitInternals(t *testing.T) { if snapshot[".boatstack/controller.json"] != "managed" || snapshot["README.md"] != "repository" { t.Fatalf("repository snapshot omitted managed or ordinary files: %#v", snapshot) } + if snapshot[".git/@semantic/HEAD"] == "" || snapshot[".git/@semantic/refs"] == "" { + t.Fatalf("repository snapshot omitted semantic Git state: %#v", snapshot) + } +} + +func TestRepositoryBytesDetectsSemanticGitMutation(t *testing.T) { + repository := t.TempDir() + runFlowGit(t, repository, "init") + writeFixture(t, repository, "README.md", []byte("repository")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + + before := repositoryBytes(t, repository) + runFlowGit(t, repository, "update-ref", "refs/heads/semantic-test", "HEAD") + after := repositoryBytes(t, repository) + if reflect.DeepEqual(before, after) { + t.Fatal("repository snapshot missed semantic Git ref mutation") + } } func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { diff --git a/package.json b/package.json index 4db0531..3436124 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:flow-sdk": "tsc -b packages/boatstack packages/boatstack-software-delivery", "test:flow-sdk": "npm run build:flow-sdk", "docs:build": "npm run build:flow-sdk && typedoc", - "docs:check": "npm run docs:build && node scripts/check-docs-api.mjs" + "docs:check": "npm run docs:build && node --test scripts/check-docs-api.test.mjs && node scripts/check-docs-api.mjs" }, "devDependencies": { "typedoc": "0.28.20", diff --git a/scripts/check-docs-api.mjs b/scripts/check-docs-api.mjs index 2f5ab63..9f3fd6f 100644 --- a/scripts/check-docs-api.mjs +++ b/scripts/check-docs-api.mjs @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; -const model = JSON.parse(await readFile("build/docs/api.json", "utf8")); const required = new Map([ ["@operatorstack/boatstack", ["defineFlow", "entry", "marked"]], [ @@ -9,37 +9,43 @@ const required = new Map([ ], ]); -function reflectionNames(node, names = new Set()) { - if (Array.isArray(node)) { - for (const value of node) reflectionNames(value, names); - } else if (node && typeof node === "object") { - if (typeof node.name === "string") names.add(node.name); - for (const value of Object.values(node)) reflectionNames(value, names); - } - return names; -} +const FUNCTION_REFLECTION = 64; -const packages = new Map( - (model.children ?? []).map((child) => [child.name, reflectionNames(child)]), -); -const failures = []; +export function documentationFailures(model) { + const packages = new Map( + (model.children ?? []).map((child) => [ + child.name, + new Set( + (child.children ?? []) + .filter((reflection) => reflection.kind === FUNCTION_REFLECTION) + .map((reflection) => reflection.name), + ), + ]), + ); + const failures = []; -for (const [packageName, exports] of required) { - const names = packages.get(packageName); - if (!names) { - failures.push(`missing documented package ${packageName}`); - continue; - } - for (const exportName of exports) { - if (!names.has(exportName)) { - failures.push(`missing documented export ${packageName}.${exportName}`); + for (const [packageName, exports] of required) { + const names = packages.get(packageName); + if (!names) { + failures.push(`missing documented package ${packageName}`); + continue; + } + for (const exportName of exports) { + if (!names.has(exportName)) { + failures.push(`missing documented export ${packageName}.${exportName}`); + } } } + return failures; } -if (failures.length > 0) { - console.error(failures.join("\n")); - process.exit(1); -} +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const model = JSON.parse(await readFile("build/docs/api.json", "utf8")); + const failures = documentationFailures(model); + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } -console.log("required public TypeScript SDK exports are documented"); + console.log("required public TypeScript SDK exports are documented"); +} diff --git a/scripts/check-docs-api.test.mjs b/scripts/check-docs-api.test.mjs new file mode 100644 index 0000000..ca663c2 --- /dev/null +++ b/scripts/check-docs-api.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { documentationFailures } from "./check-docs-api.mjs"; + +const requiredFunctions = { + "@operatorstack/boatstack": ["defineFlow", "entry", "marked"], + "@operatorstack/boatstack-software-delivery": [ + "inbox", + "trustedDelegation", + "trustedTransition", + ], +}; + +function modelWithRequiredFunctions() { + return { + children: Object.entries(requiredFunctions).map(([name, functions]) => ({ + name, + kind: 2, + children: functions.map((functionName) => ({ + name: functionName, + kind: 64, + })), + })), + }; +} + +test("accepts required top-level function exports", () => { + assert.deepEqual(documentationFailures(modelWithRequiredFunctions()), []); +}); + +test("rejects a required name that exists only on a nested member", () => { + const model = modelWithRequiredFunctions(); + const core = model.children.find( + (reflection) => reflection.name === "@operatorstack/boatstack", + ); + core.children = core.children.filter( + (reflection) => reflection.name !== "defineFlow", + ); + core.children.push({ + name: "MisleadingInterface", + kind: 256, + children: [{ name: "defineFlow", kind: 1024 }], + }); + + assert.deepEqual(documentationFailures(model), [ + "missing documented export @operatorstack/boatstack.defineFlow", + ]); +}); From b2df110dc700b1a24e86c4fdeec9e3d45aa8fa76 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 05:35:13 +0100 Subject: [PATCH 4/4] test: observe complete workflow and git state --- .github/tests/test_docs_contract.py | 138 ++++++++++++++++-- .../cmd/boatstack-helper/flow_runtime_test.go | 37 ++++- 2 files changed, 160 insertions(+), 15 deletions(-) diff --git a/.github/tests/test_docs_contract.py b/.github/tests/test_docs_contract.py index 62c4d37..915be5e 100644 --- a/.github/tests/test_docs_contract.py +++ b/.github/tests/test_docs_contract.py @@ -6,6 +6,13 @@ REPO = Path(__file__).resolve().parents[2] +def yaml_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + def workflow_triggers(workflow: str) -> dict[str, dict[str, list[str]]]: lines = workflow.splitlines() try: @@ -36,26 +43,102 @@ def workflow_triggers(workflow: str) -> dict[str, dict[str, list[str]]]: return triggers +def workflow_jobs(workflow: str) -> dict[str, dict[str, object]]: + jobs: dict[str, dict[str, object]] = {} + in_jobs = False + current_job: dict[str, object] | None = None + current_section = "" + current_step: dict[str, str] | None = None + + for line in workflow.splitlines(): + content = line.split("#", 1)[0].rstrip() + if not content.strip(): + continue + indent = len(content) - len(content.lstrip()) + stripped = content.strip() + if indent == 0: + if in_jobs and stripped != "jobs:": + break + in_jobs = stripped == "jobs:" + continue + if not in_jobs: + continue + if indent == 2 and stripped.endswith(":"): + current_job = {"permissions": {}, "steps": []} + jobs[stripped[:-1]] = current_job + current_section = "" + current_step = None + continue + if current_job is None: + continue + if indent == 4: + if stripped == "permissions:": + current_section = "permissions" + elif stripped == "steps:": + current_section = "steps" + elif ":" in stripped: + key, value = (part.strip() for part in stripped.split(":", 1)) + current_job[key] = yaml_scalar(value) + current_section = "" + continue + if indent == 6 and current_section == "permissions" and ":" in stripped: + key, value = (part.strip() for part in stripped.split(":", 1)) + permissions = current_job["permissions"] + assert isinstance(permissions, dict) + permissions[key] = yaml_scalar(value) + continue + if indent == 6 and current_section == "steps" and stripped.startswith("- "): + current_step = {} + steps = current_job["steps"] + assert isinstance(steps, list) + steps.append(current_step) + remainder = stripped[2:] + if ":" in remainder: + key, value = (part.strip() for part in remainder.split(":", 1)) + current_step[key] = yaml_scalar(value) + continue + if indent == 8 and current_section == "steps" and current_step is not None: + if ":" in stripped: + key, value = (part.strip() for part in stripped.split(":", 1)) + current_step[key] = yaml_scalar(value) + return jobs + + +def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None: + testcase.assertEqual( + workflow_triggers(workflow), + {"push": {"branches": ["main"]}, "workflow_dispatch": {}}, + ) + jobs = workflow_jobs(workflow) + testcase.assertEqual(set(jobs), {"build", "deploy"}) + testcase.assertEqual(jobs["build"].get("if"), "github.ref == 'refs/heads/main'") + testcase.assertEqual(jobs["deploy"].get("if"), "github.ref == 'refs/heads/main'") + testcase.assertEqual(jobs["build"]["permissions"], {"contents": "read"}) + testcase.assertEqual( + jobs["deploy"]["permissions"], + {"pages": "write", "id-token": "write"}, + ) + build_steps = jobs["build"]["steps"] + deploy_steps = jobs["deploy"]["steps"] + testcase.assertIsInstance(build_steps, list) + testcase.assertIsInstance(deploy_steps, list) + testcase.assertIn("npm run docs:check", [step.get("run") for step in build_steps]) + testcase.assertIn("actions/configure-pages@v6", [step.get("uses") for step in build_steps]) + testcase.assertIn("actions/upload-pages-artifact@v5", [step.get("uses") for step in build_steps]) + testcase.assertIn("actions/deploy-pages@v5", [step.get("uses") for step in deploy_steps]) + testcase.assertNotIn("release.yml", json.dumps(jobs)) + testcase.assertNotIn("gh release", json.dumps(jobs)) + + class DocumentationContractTests(unittest.TestCase): def test_required_ci_validates_documentation(self) -> None: ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() - self.assertIn("npm run docs:check", ci) + steps = workflow_jobs(ci)["flow-sdk"]["steps"] + self.assertIn("npm ci && npm run docs:check", [step.get("run") for step in steps]) def test_pages_deployment_is_main_only_and_release_independent(self) -> None: workflow = (REPO / ".github" / "workflows" / "docs-pages.yml").read_text() - self.assertEqual( - workflow_triggers(workflow), - {"push": {"branches": ["main"]}, "workflow_dispatch": {}}, - ) - self.assertEqual(workflow.count("if: github.ref == 'refs/heads/main'"), 2) - self.assertIn("npm run docs:check", workflow) - self.assertIn("actions/configure-pages@v6", workflow) - self.assertIn("actions/upload-pages-artifact@v5", workflow) - self.assertIn("actions/deploy-pages@v5", workflow) - self.assertIn("pages: write", workflow) - self.assertIn("id-token: write", workflow) - self.assertNotIn("release.yml", workflow) - self.assertNotIn("gh release", workflow) + assert_pages_contract(self, workflow) def test_trigger_parser_rejects_comment_substitution(self) -> None: malformed = """on: @@ -71,6 +154,33 @@ def test_trigger_parser_rejects_comment_substitution(self) -> None: {"push": {}, "pull_request": {}, "workflow_dispatch": {}}, ) + def test_job_parser_rejects_commented_controls(self) -> None: + malformed = """on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + # if: github.ref == 'refs/heads/main' + permissions: + # contents: read + steps: + - name: Missing controls + # run: npm run docs:check + # uses: actions/upload-pages-artifact@v5 + deploy: + # if: github.ref == 'refs/heads/main' + permissions: + # pages: write + # id-token: write + steps: + - name: Missing deployment + # uses: actions/deploy-pages@v5 +""" + with self.assertRaises(AssertionError): + assert_pages_contract(self, malformed) + def test_typedoc_covers_both_public_packages(self) -> None: config = json.loads((REPO / "typedoc.json").read_text()) self.assertEqual(config["entryPointStrategy"], "packages") diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 369942b..85c2f4d 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1054,7 +1054,9 @@ func repositoryBytes(t *testing.T, root string) map[string]string { t.Fatal(err) } result[".git/@semantic/HEAD"] = runFlowGitOutput(t, root, "rev-parse", "--verify", "HEAD") + result[".git/@semantic/symbolic-HEAD"] = runFlowGitOutput(t, root, "symbolic-ref", "--quiet", "HEAD") result[".git/@semantic/refs"] = runFlowGitOutput(t, root, "for-each-ref", "--format=%(refname)%09%(objectname)") + result[".git/@semantic/index"] = runFlowGitOutput(t, root, "ls-files", "--stage") return result } @@ -1074,7 +1076,7 @@ func TestRepositoryBytesExcludesGitInternals(t *testing.T) { if snapshot[".boatstack/controller.json"] != "managed" || snapshot["README.md"] != "repository" { t.Fatalf("repository snapshot omitted managed or ordinary files: %#v", snapshot) } - if snapshot[".git/@semantic/HEAD"] == "" || snapshot[".git/@semantic/refs"] == "" { + if snapshot[".git/@semantic/HEAD"] == "" || snapshot[".git/@semantic/symbolic-HEAD"] == "" || snapshot[".git/@semantic/refs"] == "" || snapshot[".git/@semantic/index"] == "" { t.Fatalf("repository snapshot omitted semantic Git state: %#v", snapshot) } } @@ -1094,6 +1096,39 @@ func TestRepositoryBytesDetectsSemanticGitMutation(t *testing.T) { } } +func TestRepositoryBytesDetectsSymbolicHeadMutation(t *testing.T) { + repository := t.TempDir() + runFlowGit(t, repository, "init") + writeFixture(t, repository, "README.md", []byte("repository")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + runFlowGit(t, repository, "branch", "same-commit") + + before := repositoryBytes(t, repository) + runFlowGit(t, repository, "symbolic-ref", "HEAD", "refs/heads/same-commit") + after := repositoryBytes(t, repository) + if reflect.DeepEqual(before, after) { + t.Fatal("repository snapshot missed symbolic HEAD mutation") + } +} + +func TestRepositoryBytesDetectsIndexOnlyMutation(t *testing.T) { + repository := t.TempDir() + runFlowGit(t, repository, "init") + writeFixture(t, repository, "README.md", []byte("repository")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + + before := repositoryBytes(t, repository) + writeFixture(t, repository, "README.md", []byte("staged")) + runFlowGit(t, repository, "add", "README.md") + writeFixture(t, repository, "README.md", []byte("repository")) + after := repositoryBytes(t, repository) + if reflect.DeepEqual(before, after) { + t.Fatal("repository snapshot missed index-only mutation") + } +} + func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { // control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters repository := flowRepository(t)