From 2ecf82db0567335cbbe1bfaad7aada2a75bb7844 Mon Sep 17 00:00:00 2001 From: sammiller Date: Sun, 20 Sep 2026 13:40:53 -0700 Subject: [PATCH] feat: enforce current runtime and source ownership policy --- .github/workflows/pr-gate.yml | 31 +- README.md | 3 + docs/platform-bootstrap.md | 6 +- docs/runtime-ownership.md | 70 +++ eng/policy/runtime-ownership.json | 467 ++++++++++++++++++ eng/provenance/files.json | 4 + eng/runtime_ownership.py | 373 ++++++++++++++ eng/test_runtime_ownership.py | 196 ++++++++ .../ArcForges.ContentSandbox.csproj | 6 +- 9 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 docs/runtime-ownership.md create mode 100644 eng/policy/runtime-ownership.json create mode 100644 eng/runtime_ownership.py create mode 100644 eng/test_runtime_ownership.py diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 492053c..1a63357 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -35,7 +35,7 @@ jobs: if ($LASTEXITCODE) { throw 'Invalid package version.' } "value=$version" >> $env:GITHUB_OUTPUT managed-packages: - needs: [allocate-version, native, secret-scan, repository-hooks, design-policy, licence-policy, provenance-policy, reference-policy] + needs: [allocate-version, native, secret-scan, repository-hooks, design-policy, licence-policy, provenance-policy, reference-policy, runtime-policy] uses: ./.github/workflows/package-validation.yml with: version: ${{ needs.allocate-version.outputs.version }} @@ -145,6 +145,33 @@ jobs: if-no-files-found: error retention-days: 30 + runtime-policy: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + - name: Reject runtime, assignment and retired-scaffold drift + run: python -m unittest discover -s eng -p test_runtime_ownership.py -v + - name: Verify current owner and immutable family snapshots + run: python eng/runtime_ownership.py --evaluate-managed + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: runtime-policy-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/evidence/runtime-ownership.json + if-no-files-found: error + retention-days: 30 + provenance-policy: strategy: fail-fast: false @@ -176,7 +203,7 @@ jobs: ci: if: always() - needs: [allocate-version, managed-packages, native, secret-scan, repository-hooks, design-policy, licence-policy, provenance-policy, reference-policy] + needs: [allocate-version, managed-packages, native, secret-scan, repository-hooks, design-policy, licence-policy, provenance-policy, reference-policy, runtime-policy] runs-on: ubuntu-latest env: CI_NEEDS: ${{ toJSON(needs) }} diff --git a/README.md b/README.md index 1b34f9c..1e81aa1 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,6 @@ There are no Git submodules. Contracts publishes independently from its own revi [AGPL-3.0-only](LICENSE). See [NOTICE.md](NOTICE.md) and the [dependency register](docs/compliance/third-party-license-register.md) for attribution. + +[Runtime and source ownership](docs/runtime-ownership.md) records the current nine-owner +policy, bounded runtime checks and retained bootstrap/scaffold dispositions. diff --git a/docs/platform-bootstrap.md b/docs/platform-bootstrap.md index 49e57bb..f51cbc1 100644 --- a/docs/platform-bootstrap.md +++ b/docs/platform-bootstrap.md @@ -1,5 +1,9 @@ # DesktopPlatform extraction and package pipeline +This is historical extraction evidence at the source revisions below. The current +[runtime and ownership policy](runtime-ownership.md), reviewed on 2026-09-20, governs +repository/runtime assignments; the old Design snapshot is not current authority. + ## Initial extraction plan Base: `99bfe7d695ed0d65a0d035af7d219fc9b86100f5` in `ArcForges/DesktopPlatform`. @@ -28,7 +32,7 @@ Removal is an ownership extraction, not a claim that new product or proto implem Shared placeholder libraries are retained for follow-on implementation but are not release artifacts. The first publication supported build policy. The subsequent [native package release](native-package-release.md) admits the four existing Windows ABIs with their real native closure and package-consumer validation. -This bootstrap does not close every WP02 obligation across all ten repositories or the WP06 native gates. +This bootstrap does not close every WP02 obligation across all nine implementation repositories or the WP06 native gates. ## Local validation evidence diff --git a/docs/runtime-ownership.md b/docs/runtime-ownership.md new file mode 100644 index 0000000..9a72971 --- /dev/null +++ b/docs/runtime-ownership.md @@ -0,0 +1,70 @@ +# Runtime and source ownership + +WP00.05 implements the [current Design profile](https://github.com/ArcForges/ArcForges-Design/blob/e2dd78058ce2d4bd1a8434a34d049bbc1158eacb/docs/architecture/30-runtime-and-source-ownership-policy.md). +The registry is `eng/policy/runtime-ownership.json`. Nine implementation owners +are independent; Design is documentation authority, and ArcChat is an embedded +assistant/companion feature. Policy does not create another runtime package. + +```text +python -m unittest discover -s eng -p test_runtime_ownership.py -v +python eng/runtime_ownership.py --evaluate-managed +``` + +Default verification reads this checkout and fetches the eight other owners at +their exact recorded commits into disposable isolated checkouts. It validates the +fixed Design identity, real historical extraction tree, all current project and +source inventories, declared runtime configuration, and the Contracts naming scan. +It never builds adjacent source. Contracts and Mobile do not import AGPL tooling. +Pass all nine `--repository Owner=absolute-root` arguments for a fresh family audit; +`--design-root` may use a local Design Git checkout containing the bound commit. +Reports retain actual source commit/dirty state, file/project counts, policy digest +and findings under ignored `artifacts/evidence/`. A current family audit is required +after merges; immutable snapshots alone cannot establish that current main conforms. + +The project-role inventory covers every existing build manifest, including tooling, +tests, native IDE adapters and runtime-package containers. Source files additionally +require the existing complete provenance assignment. Licence/reference checks remain +independent. Registered old product trees cannot be restored into DesktopPlatform; +the excluded Notes canvas/slides paths remain rejected even with a new inventory row. + +The AOT source check follows explicit local imports and nearest automatic directory +build files, rejects false/conditional required declarations and unknown dynamic +imports, and distinguishes libraries, hosts and build/test programs. It does not +implement an MSBuild evaluator. `--evaluate-managed` additionally invokes the pinned +SDK only for this owner's production hosts/libraries in Debug and Release; their +actual evaluated property must agree. Existing native/managed package-consumer CI +continues to prove execution. JavaScript IDE adapters cannot inherit .NET runtime +properties. The runtime check parses project/dependency/deployment inputs, not +arbitrary historical prose; its bounded checks do not prove whole-program behavior. + +The registered AI Hello Workflow requires its direct Workers AI binding, one exact +Workflow and no alternate Container/environment. Cloud has one Container bridge, +and Web has React static assets. The Kotlin Android bootstrap keeps its observed +prerelease identity, JVM 21 and development-only preview. These registrations must +be reviewed when their scheduled product work changes the actual structure. +They never authorize another agent runtime, production Node host, iOS deliverable, +old database provider, or unregistered project/source owner. + +## Reviewed claim corrections + +Reviewed on 2026-09-20 against the pinned Design authority. Each correction preserves +existing Hello/probe APIs, published package identities and runtime behavior. + +| Owner and path | Previous claim | Accepted replacement and verification | +|---|---|---| +| Design WP00.05 | Ten implementation repositories | Nine owners; Design remains documentation authority. Full corpus preview and Design PR29 review. | +| DesktopPlatform `docs/platform-bootstrap.md` | Ten-repository completion boundary; original Design pin could appear current | Nine current owners; extraction results explicitly revision-bound. Historical results preserved. | +| DesktopPlatform ContentSandbox project comment | Four desktop heads and removed layout document | Parent-bound Native AOT helper scaffold; architecture27 and WP11/13 own complete behavior. Source property/evaluated build and existing CI checked. | +| Contracts npm proto/API-client READMEs | React Native/Hermes remained a mobile delivery target | TypeScript Web; Android uses generated Kotlin gRPC-Web. Packaged README bytes and published candidates are checked after merge. | +| Contracts `docs/architecture.md` | Native gRPC could appear an alternative current public business transport | Binary gRPC-Web; existing native Hello fixtures explicitly compatibility-only. No API or fixture removal. | +| Contracts `docs/bootstrap-plan.md` | Original RN direction could escape its historical scope | Source-bound original plan explicitly reviewed as historical; results preserved, current authority linked. | +| Cloud README/bootstrap scope | PostgreSQL remained a future target | D1 business transactions; current Hello remains stateless. Existing AOT image/real protocol and main Cloudflare checks apply. | +| AI README | Cloud owned PostgreSQL transactions | Cloud owns D1 transactions; sole Workflow unchanged. Source/bundle tests and main real model/tool/model gate remain required. | + +The registry records old monorepo groups against extraction commit +`99bfe7d695ed0d65a0d035af7d219fc9b86100f5`, together with their current owners and +dispositions. Eleven shared placeholders remain non-packable, and the helper is +still a scaffold. Full business schemas, assistant implementations, all native +functional families/RIDs, four Web surfaces, Android identity/toolchain migration +and commercial operation remain at their named stages. Existing snapshot evidence +does not close those gates or require recreating already absent legacy source. diff --git a/eng/policy/runtime-ownership.json b/eng/policy/runtime-ownership.json new file mode 100644 index 0000000..a0149d7 --- /dev/null +++ b/eng/policy/runtime-ownership.json @@ -0,0 +1,467 @@ +{ + "schemaVersion": 1, + "reviewedOn": "2026-09-20", + "design": { + "commit": "e2dd78058ce2d4bd1a8434a34d049bbc1158eacb", + "path": "docs/architecture/30-runtime-and-source-ownership-policy.md", + "sha256": "3be292a0fffc23dbdb0f7d1d0fe41ad250e03661d02fee79e4d4c58b8af33e2b" + }, + "owners": [ + { + "repository": "DesktopPlatform", + "runtime": "dotnet-nativeaot-libraries-and-native-cabi", + "sourceCommit": "76b40214fe018fc663dd393f812f1ecf89249cdd", + "projects": [ + { + "path": "CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcgraphics-metal-abi/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcmedia-ffmpeg-abi/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcmedia-ffmpeg-abi/windows/arcmedia_ffmpeg_abi.vcxproj", + "role": "native-build" + }, + { + "path": "native/arcslate-color-abi/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcslate-color-abi/windows/arcslate_color_abi.vcxproj", + "role": "native-build" + }, + { + "path": "native/arcslate-image-abi/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcslate-image-abi/windows/arcslate_image_abi.vcxproj", + "role": "native-build" + }, + { + "path": "native/arcslate-otio-abi/CMakeLists.txt", + "role": "native-build" + }, + { + "path": "native/arcslate-otio-abi/windows/arcslate_otio_abi.vcxproj", + "role": "native-build" + }, + { + "path": "src/Build/ArcForges.Build.Policy/ArcForges.Build.Policy.csproj", + "role": "package-container" + }, + { + "path": "src/BuildingBlocks/ArcForges.Application.Abstractions/ArcForges.Application.Abstractions.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Desktop.Experience/ArcForges.Desktop.Experience.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Desktop.Graphics/ArcForges.Desktop.Graphics.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Desktop.Preview/ArcForges.Desktop.Preview.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Desktop.RichContent/ArcForges.Desktop.RichContent.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Desktop.Text/ArcForges.Desktop.Text.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Foundation/ArcForges.Foundation.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.NativeInterop/ArcForges.NativeInterop.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Observability/ArcForges.Observability.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Persistence.Sqlite/ArcForges.Persistence.Sqlite.csproj", + "role": "aot-library" + }, + { + "path": "src/BuildingBlocks/ArcForges.Security/ArcForges.Security.csproj", + "role": "aot-library" + }, + { + "path": "src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj", + "role": "aot-host" + }, + { + "path": "src/Native/ArcForges.Native.Abstractions/ArcForges.Native.Abstractions.csproj", + "role": "aot-library" + }, + { + "path": "src/Native/ArcForges.Native.Colour.Runtime.win-x64/ArcForges.Native.Colour.Runtime.win-x64.csproj", + "role": "package-container" + }, + { + "path": "src/Native/ArcForges.Native.Colour/ArcForges.Native.Colour.csproj", + "role": "aot-library" + }, + { + "path": "src/Native/ArcForges.Native.Image.Runtime.win-x64/ArcForges.Native.Image.Runtime.win-x64.csproj", + "role": "package-container" + }, + { + "path": "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj", + "role": "aot-library" + }, + { + "path": "src/Native/ArcForges.Native.Media.Runtime.win-x64/ArcForges.Native.Media.Runtime.win-x64.csproj", + "role": "package-container" + }, + { + "path": "src/Native/ArcForges.Native.Media/ArcForges.Native.Media.csproj", + "role": "aot-library" + }, + { + "path": "src/Native/ArcForges.Native.Otio.Runtime.win-x64/ArcForges.Native.Otio.Runtime.win-x64.csproj", + "role": "package-container" + }, + { + "path": "src/Native/ArcForges.Native.Otio/ArcForges.Native.Otio.csproj", + "role": "aot-library" + }, + { + "path": "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj", + "role": "test-or-build-tool" + }, + { + "path": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "role": "test-or-build-tool" + } + ] + }, + { + "repository": "Contracts", + "runtime": "proto-generated-clients", + "sourceCommit": "d716045854e2e401a1a536ba2ee5c62ad1c4e8ab", + "projects": [ + { + "path": "build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "eng/Codegen/Codegen.csproj", + "role": "test-or-build-tool" + }, + { + "path": "eng/central-signing/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "package.json", + "role": "typescript-build" + }, + { + "path": "src/public/dotnet/ArcForges.Contracts.PublicApi/ArcForges.Contracts.PublicApi.csproj", + "role": "aot-library" + }, + { + "path": "src/public/kotlin/contract-fixtures/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "src/public/kotlin/contracts-client/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "src/public/kotlin/contracts-connect-client/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "src/public/kotlin/contracts-proto/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "src/public/ts/api-client/package.json", + "role": "typescript-build" + }, + { + "path": "src/public/ts/proto/package.json", + "role": "typescript-build" + }, + { + "path": "tests/public/HelloClient/HelloClient.csproj", + "role": "test-or-build-tool" + }, + { + "path": "tests/public/HelloHost/HelloHost.csproj", + "role": "test-or-build-tool" + }, + { + "path": "tests/public/KotlinClient/build.gradle.kts", + "role": "contract-or-test-build" + }, + { + "path": "tests/public/KotlinConnectClient/build.gradle.kts", + "role": "contract-or-test-build" + } + ] + }, + { + "repository": "ArcNotes", + "runtime": "avalonia-nativeaot", + "sourceCommit": "e40423a1b14ce8341de35748cc2a093c7c9b77a7", + "projects": [ + { + "path": "eng/ArcForges.Repository/ArcForges.Repository.csproj", + "role": "test-or-build-tool" + }, + { + "path": "src/ArcForges.ArcNotes.Core/ArcForges.ArcNotes.Core.csproj", + "role": "aot-library" + }, + { + "path": "src/ArcForges.ArcNotes/ArcForges.ArcNotes.csproj", + "role": "aot-host" + }, + { + "path": "tests/ArcForges.ArcNotes.Tests/ArcForges.ArcNotes.Tests.csproj", + "role": "test-or-build-tool" + } + ] + }, + { + "repository": "ArcScope", + "runtime": "avalonia-nativeaot", + "sourceCommit": "d247dcff36fd1123a70e5e59967a9b2294a2eeac", + "projects": [ + { + "path": "eng/ArcForges.Repository/ArcForges.Repository.csproj", + "role": "test-or-build-tool" + }, + { + "path": "src/ArcForges.ArcScope.Core/ArcForges.ArcScope.Core.csproj", + "role": "aot-library" + }, + { + "path": "src/ArcForges.ArcScope/ArcForges.ArcScope.csproj", + "role": "aot-host" + }, + { + "path": "tests/ArcForges.ArcScope.Tests/ArcForges.ArcScope.Tests.csproj", + "role": "test-or-build-tool" + } + ] + }, + { + "repository": "ArcSlate", + "runtime": "avalonia-nativeaot", + "sourceCommit": "b0d255f54fb560a534cc493d5645ca4bc7b4bd0e", + "projects": [ + { + "path": "eng/ArcForges.Repository/ArcForges.Repository.csproj", + "role": "test-or-build-tool" + }, + { + "path": "src/ArcForges.ArcSlate.Core/ArcForges.ArcSlate.Core.csproj", + "role": "aot-library" + }, + { + "path": "src/ArcForges.ArcSlate/ArcForges.ArcSlate.csproj", + "role": "aot-host" + }, + { + "path": "tests/ArcForges.ArcSlate.Tests/ArcForges.ArcSlate.Tests.csproj", + "role": "test-or-build-tool" + } + ] + }, + { + "repository": "Cloud", + "runtime": "dotnet-nativeaot-cloudflare-container", + "sourceCommit": "4571ec8692235485712d4c4e3ef4886c162f2574", + "projects": [ + { + "path": "package.json", + "role": "typescript-build" + }, + { + "path": "src/ArcForges.Cloud/ArcForges.Cloud.csproj", + "role": "aot-host" + }, + { + "path": "tests/ArcForges.Cloud.Consumer/ArcForges.Cloud.Consumer.csproj", + "role": "test-or-build-tool" + }, + { + "path": "tests/ArcForges.Cloud.Tests/ArcForges.Cloud.Tests.csproj", + "role": "test-or-build-tool" + }, + { + "path": "tests/kotlin-consumer/build.gradle.kts", + "role": "contract-or-test-build" + } + ] + }, + { + "repository": "AI", + "runtime": "cloudflare-workflow-workers-ai", + "sourceCommit": "944edfe88718fc5f72a42ea0bed1c495307c22df", + "projects": [ + { + "path": "package.json", + "role": "typescript-build" + } + ] + }, + { + "repository": "Web", + "runtime": "react-typescript-static", + "sourceCommit": "84939ca1fde0f0653d2cb4d8b8f9e5dd1057abb5", + "projects": [ + { + "path": "ArcForges.Web.esproj", + "role": "javascript-ide" + }, + { + "path": "apps/site/package.json", + "role": "typescript-build" + }, + { + "path": "package.json", + "role": "typescript-build" + }, + { + "path": "packages/ui/package.json", + "role": "typescript-build" + } + ] + }, + { + "repository": "Mobile", + "runtime": "kotlin-compose-android", + "sourceCommit": "5031d837d2e7bf9dd1b681c837c942d2b74dc65e", + "projects": [ + { + "path": "app/build.gradle.kts", + "role": "android-build" + }, + { + "path": "build.gradle.kts", + "role": "android-build" + }, + { + "path": "shared/build.gradle.kts", + "role": "android-build" + } + ] + } + ], + "retiredScaffolds": [ + { + "path": "src/ArcChat", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "retired", + "owner": "DesktopPlatform", + "reason": "Standalone host retired; reusable embedded assistant features remain assigned to WP14-17." + }, + { + "path": "src/ArcNotes", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "ArcNotes", + "reason": "Independent native product. Edgeless and Slides scaffolds are excluded, without a future hook." + }, + { + "path": "src/ArcScope", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "ArcScope", + "reason": "Independent acquisition/measurement product; no product source in shared platform." + }, + { + "path": "src/ArcSlate", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "ArcSlate", + "reason": "Independent media/timeline product; native mechanism packages remain platform-owned." + }, + { + "path": "src/Cloud", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "replaced", + "owner": "Cloud", + "reason": "Native AOT Container with D1/R2 authority; no old JIT/database or in-host Harness placement." + }, + { + "path": "src/Mobile", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "replaced", + "owner": "Mobile", + "reason": "Kotlin Android replaces .NET/RN delivery. Existing prerelease identity and JVM development preview remain until WP30." + }, + { + "path": "src/Web", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "replaced", + "owner": "Web", + "reason": "React/TypeScript browser outputs replace .NET browser host." + }, + { + "path": "src/Contracts", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "Contracts", + "reason": "Authored proto and generated clients replace old authored JSON DTO wire authority; Hello compatibility retained." + }, + { + "path": "src/SDK", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "Contracts", + "reason": "Public SDK/CLI owner; full behavior remains WP03 and subsequent consumer gates." + }, + { + "path": "src/Extensions", + "observedRepository": "DesktopPlatform", + "sourceCommit": "99bfe7d695ed0d65a0d035af7d219fc9b86100f5", + "disposition": "extracted", + "owner": "DesktopPlatform", + "reason": "Shared sandbox/extension mechanisms; catalogue business authority remains Cloud under WP41." + } + ], + "bootstrapBoundaries": { + "DesktopPlatform": "Eleven non-packable shared placeholders and ContentSandbox scaffold are not capability acceptance; native packages expose existing probes. WP01/04/07-17/53 retain their full obligations.", + "Contracts": "Hello schema and native-gRPC compatibility fixtures retained. Public business clients select binary gRPC-Web; WP03 completes production schemas and retires native-only Kotlin client at the first business release.", + "ArcNotes": "Hello native UI/transport only. Five-RID bootstrap is not full platform or Notes behavior acceptance.", + "ArcScope": "Hello native UI/transport only. Five-RID bootstrap is not full platform or acquisition behavior acceptance.", + "ArcSlate": "Hello native UI/transport only. Five-RID bootstrap is not full platform or media behavior acceptance.", + "Cloud": "Hello anonymous stateless AOT service. Full D1 modules and Container production profile remain WP21 onward.", + "AI": "Private Workflow model/tool/model Hello. Full sole production Harness remains WP52.", + "Web": "Site Hello only; account/chat/operations remain required at their scheduled producers.", + "Mobile": "Android Hello with io.github.arcforges.mobile; com.arcforges.mobile migration and stable toolchain reconciliation remain WP30 before production. JVM preview is development-only." + } +} diff --git a/eng/provenance/files.json b/eng/provenance/files.json index f406011..98480b0 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -43,6 +43,7 @@ "docs/platform-bootstrap.md", "docs/provenance.md", "docs/reference-baselines.md", + "docs/runtime-ownership.md", "eng/build/desktop-aot.props", "eng/build/desktop-rids.props", "eng/cmake/LicenceBoundary.cmake", @@ -65,6 +66,7 @@ "eng/policy/licence-boundary.json", "eng/policy/reference-baselines.json", "eng/policy/reuse-policy.json", + "eng/policy/runtime-ownership.json", "eng/provenance/NOTICE.txt", "eng/provenance/artifact-profiles/native-win-x64-r1.json", "eng/provenance/artifact-profiles/native-win-x64-r2.json", @@ -188,9 +190,11 @@ "eng/provenance/reference-inputs.json", "eng/provenance/template.json", "eng/reference_baselines.py", + "eng/runtime_ownership.py", "eng/test_design_policy.py", "eng/test_licence_boundary.py", "eng/test_reference_baselines.py", + "eng/test_runtime_ownership.py", "global.json", "native/CMakeLists.txt", "native/arcgraphics-metal-abi/CMakeLists.txt", diff --git a/eng/runtime_ownership.py b/eng/runtime_ownership.py new file mode 100644 index 0000000..37bee09 --- /dev/null +++ b/eng/runtime_ownership.py @@ -0,0 +1,373 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""WP00.05 read-only runtime/source audit; not product or deployment acceptance.""" + +from __future__ import annotations + +import argparse +from datetime import date, datetime, timezone +import hashlib +import importlib.util +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +import licence_boundary as inventory + +ROOT = Path(__file__).resolve().parents[1] +POLICY = 'eng/policy/runtime-ownership.json' +RUNTIMES = { + 'DesktopPlatform': 'dotnet-nativeaot-libraries-and-native-cabi', + 'Contracts': 'proto-generated-clients', + 'ArcNotes': 'avalonia-nativeaot', 'ArcScope': 'avalonia-nativeaot', 'ArcSlate': 'avalonia-nativeaot', + 'Cloud': 'dotnet-nativeaot-cloudflare-container', 'AI': 'cloudflare-workflow-workers-ai', + 'Web': 'react-typescript-static', 'Mobile': 'kotlin-compose-android', +} +RETIRED = {'ArcChat': ('retired', 'DesktopPlatform'), 'ArcNotes': ('extracted', 'ArcNotes'), + 'ArcScope': ('extracted', 'ArcScope'), 'ArcSlate': ('extracted', 'ArcSlate'), + 'Cloud': ('replaced', 'Cloud'), 'Mobile': ('replaced', 'Mobile'), 'Web': ('replaced', 'Web'), + 'Contracts': ('extracted', 'Contracts'), 'SDK': ('extracted', 'Contracts'), + 'Extensions': ('extracted', 'DesktopPlatform')} +TRUE = {'PublishAot', 'IsAotCompatible'} +HOSTS = {'DesktopPlatform': 'src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj', + 'Cloud': 'src/ArcForges.Cloud/ArcForges.Cloud.csproj', + **{name: f'src/ArcForges.{name}/ArcForges.{name}.csproj' for name in ('ArcNotes', 'ArcScope', 'ArcSlate')}} + + +def require(value, message): + if not value: + raise ValueError(message) + + +def fields(value, names): + require(isinstance(value, dict) and set(value) == set(names.split()), 'invalid schema fields') + + +def path(value): + require(isinstance(value, str) and value and '\\' not in value and ':' not in value, + 'invalid policy path') + p = PurePosixPath(value) + require(not p.is_absolute() and '..' not in p.parts and value == p.as_posix(), 'unsafe policy path') + return value + + +def digest(value, length=40): + require(isinstance(value, str) and re.fullmatch('[0-9a-f]{' + str(length) + '}', value), 'invalid digest') + + +def document(root, relative): + return json.loads(inventory.read(root, path(relative)), object_pairs_hook=inventory.unique) + + +def role(owner, relative): + if relative.endswith('.csproj'): + if relative.startswith(('tests/', 'eng/')): + return 'test-or-build-tool' + require(relative.startswith('src/'), 'unassigned managed source project') + if '/Build/' in relative or '.Runtime.' in relative: + require(owner == 'DesktopPlatform', 'unassigned runtime package') + return 'package-container' + if owner == 'Cloud' or (owner in {'ArcNotes', 'ArcScope', 'ArcSlate'} and '.Core/' not in relative) or '/DesktopHelpers/' in relative: + require(HOSTS.get(owner) == relative, 'unassigned runtime host') + return 'aot-host' + require(owner in {'DesktopPlatform', 'Contracts', 'ArcNotes', 'ArcScope', 'ArcSlate'}, 'unassigned managed runtime') + return 'aot-library' + if relative.endswith('.esproj'): + require(owner == 'Web', 'unassigned JavaScript IDE project') + return 'javascript-ide' + if relative.endswith('.vcxproj') or relative.endswith('CMakeLists.txt'): + require(owner == 'DesktopPlatform', 'native build outside its owner') + return 'native-build' + if relative.endswith('package.json'): + require(owner in {'Contracts', 'Cloud', 'AI', 'Web'}, 'unassigned npm runtime') + return 'typescript-build' + if relative.endswith('build.gradle.kts'): + require(owner in {'Contracts', 'Cloud', 'Mobile'}, 'unassigned Gradle runtime') + return 'android-build' if owner == 'Mobile' else 'contract-or-test-build' + raise ValueError('unassigned project kind: ' + relative) + + +def validate_policy(value): + fields(value, 'schemaVersion reviewedOn design owners retiredScaffolds bootstrapBoundaries') + require(type(value['schemaVersion']) is int and value['schemaVersion'] == 1, 'invalid schema version') + require(date.fromisoformat(value['reviewedOn']).isoformat() == value['reviewedOn'], 'invalid review date') + fields(value['design'], 'commit path sha256') + digest(value['design']['commit']) + digest(value['design']['sha256'], 64) + require(value['design']['path'] == 'docs/architecture/30-runtime-and-source-ownership-policy.md', 'wrong Design authority') + require(isinstance(value['owners'], list), 'invalid owners') + seen = set() + for row in value['owners']: + fields(row, 'repository runtime sourceCommit projects') + owner = row['repository'] + require(owner in RUNTIMES and owner not in seen, 'unknown or duplicate owner') + seen.add(owner) + require(row['runtime'] == RUNTIMES[owner], 'runtime selection changed: ' + owner) + digest(row['sourceCommit']) + require(isinstance(row['projects'], list) and row['projects'], 'missing project ownership') + projects = set() + for project in row['projects']: + fields(project, 'path role') + relative = path(project['path']) + require(relative not in projects, 'duplicate project') + projects.add(relative) + require(project['role'] == role(owner, relative), 'project role does not match owner') + require(seen == set(RUNTIMES), 'missing repository owner') + require(isinstance(value['bootstrapBoundaries'], dict) and set(value['bootstrapBoundaries']) == seen, + 'missing bootstrap boundary') + require(all(isinstance(x, str) and x.strip() for x in value['bootstrapBoundaries'].values()), 'empty bootstrap boundary') + require(isinstance(value['retiredScaffolds'], list), 'missing retired dispositions') + seen = set() + for row in value['retiredScaffolds']: + fields(row, 'path observedRepository sourceCommit disposition owner reason') + relative = path(row['path']) + group = relative.removeprefix('src/') + require(relative == 'src/' + group and group in RETIRED and group not in seen, 'invalid retired path') + seen.add(group) + require((row['disposition'], row['owner']) == RETIRED[group], 'retired owner/disposition changed') + require(row['observedRepository'] == 'DesktopPlatform' and row['sourceCommit'] == '99bfe7d695ed0d65a0d035af7d219fc9b86100f5', + 'historical extraction identity changed') + require(isinstance(row['reason'], str) and row['reason'].strip(), 'missing disposition reason') + require(seen == set(RETIRED), 'missing historical disposition') + + +def managed_inputs(root, project, files): + """Walk explicit local imports and nearest automatic directory build inputs. + + Conditions cannot make required AOT declarations optional. This intentionally + rejects unsupported dynamic imports rather than claiming to evaluate MSBuild. + """ + result = [] + visited = set() + + def visit(relative, conditional=False): + identity = (relative, conditional) + require(relative in files, 'unregistered build input: ' + relative) + if identity in visited: + return + visited.add(identity) + tree = ET.fromstring(inventory.read(root, relative)) + require(tree.tag == 'Project', 'unexpected MSBuild namespace/root') + + def walk(element, condition): + condition = condition or bool(element.get('Condition')) or element.tag in {'Choose', 'When', 'Otherwise'} + result.append((relative, element, condition)) + if element.tag == 'Import': + target = element.get('Project', '') + require(target and not any(c in target for c in '$@*?;'), 'dynamic import requires review: ' + relative) + resolved = (root / PurePosixPath(relative).parent / target.replace('\\', '/')).resolve() + require(resolved.is_relative_to(root.resolve()), 'build import escapes owner') + visit(resolved.relative_to(root.resolve()).as_posix(), condition) + for child in element: + walk(child, condition) + walk(tree, conditional) + + for name in ('Directory.Build.props', 'Directory.Build.targets'): + parent = PurePosixPath(project).parent + while True: + candidate = (parent / name).as_posix() + if candidate in files: + visit(candidate) + break + if parent == PurePosixPath('.'): + break + parent = parent.parent + visit(project) + return result + + +def check_managed(root, project, selected_role, files): + rows = managed_inputs(root, project, files) + needed = {'PublishAot'} if selected_role == 'aot-host' else {'IsAotCompatible'} if selected_role == 'aot-library' else set() + observed = set() + framework = False + for relative, element, conditional in rows: + if selected_role in {'aot-host', 'aot-library'} and element.tag in {'TargetFramework', 'TargetFrameworks'}: + require(element.tag == 'TargetFramework' and (element.text or '').strip() == 'net10.0' and not conditional, + 'unregistered managed framework: ' + project) + framework = True + if selected_role == 'aot-host' and element.tag == 'IsAotCompatible': + require((element.text or '').strip().lower() == 'true' and not conditional, 'host AOT compatibility override') + if element.tag in needed: + require((element.text or '').strip().lower() == 'true' and not conditional, + 'required AOT property absent, false or conditional: ' + project + ': ' + element.tag) + observed.add(element.tag) + if selected_role == 'aot-library' and element.tag == 'OutputType': + require((element.text or '').strip().lower() == 'library', 'unassigned executable: ' + project) + if selected_role == 'javascript-ide': + require(element.tag not in TRUE | {'TargetFramework', 'TargetFrameworks'}, 'IDE adapter inherited managed runtime') + require(observed == needed, 'missing AOT declaration: ' + project) + require(not needed or framework, 'missing managed framework: ' + project) + return {'path': project, 'role': selected_role, 'requiredProperties': sorted(needed), + 'sourceInputs': sorted({x[0] for x in rows})} + + +def check_runtime_configuration(root, owner, files): + for relative in files: + p = PurePosixPath(relative) + if p.suffix in {'.csproj', '.props', '.targets', '.esproj'}: + tree = ET.fromstring(inventory.read(root, relative)) + require('BlazorWebAssembly' not in tree.get('Sdk', ''), 'retired browser runtime') + for element in tree.iter(): + if element.tag in {'UseMaui', 'UseWPF', 'UseWindowsForms'}: + require((element.text or '').strip().lower() == 'false', 'retired UI runtime') + if element.tag in {'PackageReference', 'PackageVersion'}: + name = (element.get('Include') or element.get('Update') or '').lower() + require(not name.startswith(('reactnative', 'electronnet', 'microsoft.aspnetcore.components.webassembly')), + 'retired dependency: ' + name) + if owner == 'Cloud': + require(not name.startswith(('npgsql', 'microsoft.entityframeworkcore.sqlserver', 'mysql')), + 'retired Cloud database provider: ' + name) + elif p.name == 'package.json': + package = document(root, relative) + for section in ('dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'): + for name, version in package.get(section, {}).items(): + names = [name] + if str(version).startswith('npm:'): + alias = str(version)[4:] + end = alias.find('@', 1) + names.append(alias if end < 0 else alias[:end]) + for dependency in names: + require(not dependency.lower().startswith(('react-native', '@react-native/', 'hermes-engine', 'electron', 'ollama')), + 'retired runtime dependency: ' + dependency) + elif p.name in {'build.gradle.kts', 'build.gradle', 'settings.gradle.kts', 'settings.gradle'} and owner == 'Mobile': + text = inventory.read(root, relative) + text = re.sub(r'/\*.*?\*/|//[^\n]*', '', text, flags=re.S) + require(not re.search(r'\b(?:iosArm64|iosX64|iosSimulatorArm64|macosArm64|macosX64)\s*\(', text), 'unsupported Mobile product target') + require('com.facebook.react' not in text and 'nativeDistributions' not in text, 'retired Mobile runtime/distribution') + if owner in {'AI', 'Cloud', 'Web'}: + config = document(root, 'wrangler.json') + require(not config.get('env'), 'unregistered runtime environment override') + if owner == 'AI': + require(config.get('ai', {}).get('binding') == 'AI', 'missing direct Workers AI binding') + workflows = config.get('workflows') + require(isinstance(workflows, list) and len(workflows) == 1 and workflows[0] == { + 'name': 'arcforges-ai-hello', 'binding': 'HELLO_AGENT', 'class_name': 'HelloAgentWorkflow'}, + 'missing or extra model-loop Workflow binding') + require(config.get('main') == 'src/index.ts' and not config.get('containers'), 'incorrect AI host') + else: + require(not config.get('ai') and not config.get('workflows'), 'AI runtime outside its owner') + if owner == 'Cloud': + require(config.get('main') == 'worker/index.ts' and len(config.get('containers', [])) == 1, + 'missing or extra Cloud host') + require(config['containers'][0].get('class_name') == 'CloudContainer', 'incorrect Cloud Container owner') + else: + require(not config.get('main') and not config.get('containers') and config.get('assets', {}).get('directory') == './apps/site/build/client', + 'Web must deploy its static browser candidate') + require('react' in document(root, 'apps/site/package.json').get('dependencies', {}), 'missing React browser runtime') + if owner == 'Mobile': + app = inventory.read(root, 'app/build.gradle.kts') + app = re.sub(r'/\*.*?\*/|//[^\n]*', ' ', app, flags=re.S) + require(re.search(r'\bapplicationId\s*=\s*"io.github.arcforges.mobile"', app), 'prerelease identity changed before WP30') + require('libs.plugins.compose.compiler' in app and re.search(r'\bcompose\s*=\s*true', app), 'missing Android Compose runtime') + require('JavaVersion.VERSION_21' in app and 'JvmTarget.JVM_21' in app, 'Android compiler target changed') + + +def audit(root, row): + owner = row['repository'] + files = inventory.inventory(root) + before = {'commit': inventory.git(root, 'rev-parse', 'HEAD'), 'dirty': bool(inventory.git(root, 'status', '--porcelain'))} + require(inventory.git(root, 'remote', 'get-url', 'origin').removesuffix('.git') == 'https://github.com/ArcForges/' + owner, + 'incorrect repository origin: ' + owner) + declared = document(root, 'eng/provenance/files.json') + require(declared.get('repository') == owner and len(declared['firstParty']) == len(set(declared['firstParty'])), 'invalid source owner') + require(not set(declared['firstParty']) & set(declared['reused']), 'duplicate source assignment') + require(set(files) == set(declared['firstParty']) | set(declared['reused']), 'unassigned or stale source inventory: ' + owner) + licence = inventory.audit(root, owner) + require({p['path'] for p in licence['projects']} == {p['path'] for p in row['projects']}, 'unassigned or stale project: ' + owner) + if owner == 'DesktopPlatform': + require(not any(p.startswith(tuple('src/' + x + '/' for x in RETIRED)) for p in files), 'retired scaffold restored') + require(not any('/ArcNotes.Edgeless/' in '/' + p or '/ArcNotes.Slides/' in '/' + p for p in files), 'excluded Notes scaffold restored') + managed = [] + for project in row['projects']: + if project['path'].endswith(('.csproj', '.esproj')): + managed.append(check_managed(root, project['path'], project['role'], set(files))) + check_runtime_configuration(root, owner, files) + require(before == {'commit': inventory.git(root, 'rev-parse', 'HEAD'), 'dirty': bool(inventory.git(root, 'status', '--porcelain'))}, 'source state changed during audit') + return {'repository': owner, **before, 'files': len(files), 'projects': len(row['projects']), + 'runtime': row['runtime'], 'managedSourceChecks': managed, 'result': 'passed'} + + +def checkout(parent, owner, commit): + target = parent / owner + subprocess.run(['git', 'init', '-q', str(target)], check=True) + subprocess.run(['git', '-C', str(target), 'remote', 'add', 'origin', 'https://github.com/ArcForges/' + owner + '.git'], check=True) + subprocess.run(['git', '-C', str(target), '-c', 'core.autocrlf=false', 'fetch', '--quiet', '--depth=1', 'origin', commit], check=True) + subprocess.run(['git', '-C', str(target), '-c', 'core.autocrlf=false', 'checkout', '--quiet', '--detach', 'FETCH_HEAD'], check=True) + require(inventory.git(target, 'rev-parse', 'HEAD') == commit, 'fetched snapshot identity mismatch') + return target + + +def naming(roots): + source = roots['Contracts'] / 'eng/check_naming.py' + spec = importlib.util.spec_from_file_location('owned_naming', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + policy = module.load_policy() + return [module.scan_repository(root, owner, policy) for owner, root in roots.items()] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repository', action='append', help='Owner=absolute-root; supply all nine for fresh family audit') + parser.add_argument('--design-root', type=Path) + parser.add_argument('--evaluate-managed', action='store_true', help='evaluate only this DesktopPlatform checkout') + parser.add_argument('--report', type=Path, default=ROOT / 'artifacts/evidence/runtime-ownership.json') + args = parser.parse_args() + result = {'substep': 'WP00.05', 'checkedAt': datetime.now(timezone.utc).isoformat(), + 'evidenceClass': 'source-runtime-and-ownership-policy', 'repositories': [], + 'limitations': 'No product, deployment, store or commercial completion inferred; evaluated builds and real candidates are separate evidence.'} + try: + value = document(ROOT, POLICY) + validate_policy(value) + result['policySha256'] = hashlib.sha256(inventory.read(ROOT, POLICY).encode()).hexdigest() + with tempfile.TemporaryDirectory(prefix='arcforges-runtime-') as directory: + temporary = Path(directory) + design = args.design_root.resolve() if args.design_root else checkout(temporary, 'ArcForges-Design', value['design']['commit']) + body = subprocess.check_output(['git', '-C', str(design), 'show', value['design']['commit'] + ':' + value['design']['path']]) + require(hashlib.sha256(body.replace(b'\r\n', b'\n')).hexdigest() == value['design']['sha256'], 'Design identity mismatch') + roots = {} + if args.repository: + for entry in args.repository: + owner, separator, location = entry.partition('=') + require(separator and owner in RUNTIMES and owner not in roots and Path(location).is_absolute(), 'invalid selected root') + roots[owner] = Path(location).resolve() + require(set(roots) == set(RUNTIMES), 'fresh audit requires all nine roots') + else: + roots = {row['repository']: ROOT if row['repository'] == 'DesktopPlatform' else checkout(temporary, row['repository'], row['sourceCommit']) for row in value['owners']} + historical = value['retiredScaffolds'][0]['sourceCommit'] + tree = set(inventory.git(roots['DesktopPlatform'], 'ls-tree', '--name-only', historical, 'src/').splitlines()) + require({'src/' + group for group in RETIRED} <= tree, 'historical disposition source does not resolve') + result['historicalDispositionSource'] = historical + for row in value['owners']: + result['repositories'].append(audit(roots[row['repository']], row)) + result['naming'] = naming(roots) + require(all(not row['findings'] for row in result['naming']), 'naming policy failed') + if args.evaluate_managed: + managed = [] + row = next(x for x in value['owners'] if x['repository'] == 'DesktopPlatform') + for project in row['projects']: + if project['role'] not in {'aot-host', 'aot-library'}: + continue + for configuration in ('Debug', 'Release'): + prop = 'PublishAot' if project['role'] == 'aot-host' else 'IsAotCompatible' + output = subprocess.check_output(['dotnet', 'msbuild', project['path'], '-getProperty:' + prop, '-p:Configuration=' + configuration, '-verbosity:quiet'], cwd=ROOT, text=True).strip() + require(output.lower() == 'true', 'evaluated runtime mismatch: ' + project['path']) + managed.append({'path': project['path'], 'configuration': configuration, prop: output}) + result['evaluatedManaged'] = managed + result['result'] = 'passed' + code = 0 + except (ValueError, OSError, KeyError, TypeError, ET.ParseError, subprocess.CalledProcessError) as error: + result.update(result='failed', error=str(error)) + code = 1 + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + print(json.dumps({'result': result['result'], 'repositories': len(result['repositories']), 'error': result.get('error')})) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/eng/test_runtime_ownership.py b/eng/test_runtime_ownership.py new file mode 100644 index 0000000..ae3fb25 --- /dev/null +++ b/eng/test_runtime_ownership.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Independent invalid runtime/ownership fixtures; no provider or model calls.""" + +import copy +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +import runtime_ownership as policy + + +class RuntimeOwnershipTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='arcforges-runtime-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.files = set() + + def write(self, path, value): + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(value if isinstance(value, str) else json.dumps(value), encoding='utf-8') + self.files.add(path) + + def check(self, owner): + policy.check_runtime_configuration(self.root, owner, self.files) + + def test_registry_rejects_missing_duplicate_and_extra_owner(self): + original = policy.document(policy.ROOT, policy.POLICY) + policy.validate_policy(original) + for mutation in ('missing', 'duplicate', 'unknown', 'runtime', 'role', 'digest', 'field', 'retired'): + with self.subTest(mutation=mutation): + value = copy.deepcopy(original) + if mutation == 'missing': value['owners'].pop() + if mutation == 'duplicate': value['owners'].append(value['owners'][0]) + if mutation == 'unknown': value['owners'][0]['repository'] = 'ArcChat' + if mutation == 'runtime': value['owners'][0]['runtime'] = 'managed-jit' + if mutation == 'role': value['owners'][0]['projects'][0]['role'] = 'test-or-build-tool' + if mutation == 'digest': value['design']['commit'] = 'main' + if mutation == 'field': value['allowEverything'] = True + if mutation == 'retired': value['retiredScaffolds'].pop() + with self.assertRaises(ValueError): policy.validate_policy(value) + + def test_unsafe_policy_paths_and_ambiguous_json(self): + for path in ('../outside', '/absolute', 'C:/absolute', 'src\\app', 'a/../b', './src'): + with self.subTest(path=path), self.assertRaises(ValueError): policy.path(path) + self.write('duplicate.json', '{"owner":1,"owner":2}') + with self.assertRaises(ValueError): policy.document(self.root, 'duplicate.json') + + def test_complete_aot_host_then_false_missing_and_conditional_values(self): + project = 'src/Host/Host.csproj' + for properties, passes in [('true', True), ('', False), + ('false', False), + ('true', False), + ('truefalse', False)]: + self.write(project, 'net10.0' + properties + '') + if passes: policy.check_managed(self.root, project, 'aot-host', self.files) + else: + with self.assertRaises(ValueError): policy.check_managed(self.root, project, 'aot-host', self.files) + + def test_false_override_in_automatic_targets_is_not_hidden_by_true_project(self): + self.write('src/Host.csproj', 'true') + self.write('Directory.Build.targets', 'false') + with self.assertRaisesRegex(ValueError, 'AOT property'): + policy.check_managed(self.root, 'src/Host.csproj', 'aot-host', self.files) + + def test_imports_are_followed_and_cannot_escape_or_be_conditional(self): + self.write('aot.props', 'net10.0true') + self.write('src/Host.csproj', '') + policy.check_managed(self.root, 'src/Host.csproj', 'aot-host', self.files) + for imported in ('../../outside.props', '$(External)/aot.props', 'missing.props'): + self.write('src/Host.csproj', '') + with self.assertRaises(ValueError): policy.check_managed(self.root, 'src/Host.csproj', 'aot-host', self.files) + self.write('src/Host.csproj', '') + with self.assertRaises(ValueError): policy.check_managed(self.root, 'src/Host.csproj', 'aot-host', self.files) + + def test_library_cannot_become_unassigned_executable(self): + self.write('Library.csproj', 'trueExe') + with self.assertRaisesRegex(ValueError, 'unassigned executable'): + policy.check_managed(self.root, 'Library.csproj', 'aot-library', self.files) + with self.assertRaisesRegex(ValueError, 'unassigned runtime host'): + policy.role('Cloud', 'src/AnotherHost/AnotherHost.csproj') + + def test_javascript_ide_never_inherits_managed_runtime(self): + self.write('Web.esproj', 'true') + with self.assertRaisesRegex(ValueError, 'IDE adapter'): + policy.check_managed(self.root, 'Web.esproj', 'javascript-ide', self.files) + + def test_old_ui_and_database_manifests_fail(self): + for xml, owner in [('', 'Web'), + ('true', 'ArcNotes'), + ('', 'Cloud')]: + self.write('app.csproj', xml) + with self.assertRaisesRegex(ValueError, 'retired'): self.check(owner) + + def test_obsolete_npm_dependencies_include_aliases(self): + for name, version in [('react-native', '1.0.0'), ('alias', 'npm:react-native@1.0.0'), + ('alias', 'npm:@react-native/runtime'), ('hermes-engine', '1.0.0'), ('electron', '1.0.0')]: + self.write('package.json', {'dependencies': {name: version}}) + with self.assertRaisesRegex(ValueError, 'retired runtime'): self.check('AI') + + def workflow(self): + return {'main': 'src/index.ts', 'ai': {'binding': 'AI'}, 'workflows': [ + {'name': 'arcforges-ai-hello', 'binding': 'HELLO_AGENT', 'class_name': 'HelloAgentWorkflow'}]} + + def test_exact_workflow_binding_and_direct_ai_are_required(self): + self.write('wrangler.json', self.workflow()) + self.check('AI') + for mutation in ('missing', 'second', 'binding', 'ai', 'container', 'environment'): + value = self.workflow() + if mutation == 'missing': value['workflows'] = [] + if mutation == 'second': value['workflows'].append(dict(value['workflows'][0])) + if mutation == 'binding': value['workflows'][0]['binding'] = 'OTHER' + if mutation == 'ai': del value['ai'] + if mutation == 'container': value['containers'] = [{}] + if mutation == 'environment': value['env'] = {'production': {'workflows': [{}]}} + self.write('wrangler.json', value) + with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.check('AI') + + def test_cloud_cannot_acquire_an_ai_loop_or_second_host(self): + base = {'main': 'worker/index.ts', 'containers': [{'class_name': 'CloudContainer'}]} + self.write('wrangler.json', base) + self.check('Cloud') + for value in ({**base, 'ai': {'binding': 'AI'}}, {**base, 'workflows': [{}]}, + {**base, 'containers': []}, {**base, 'containers': [{}, {}]}): + self.write('wrangler.json', value) + with self.assertRaises(ValueError): self.check('Cloud') + + def test_web_requires_react_static_assets_not_runtime_server(self): + self.write('apps/site/package.json', {'dependencies': {'react': '19.3.0'}}) + self.write('wrangler.json', {'assets': {'directory': './apps/site/build/client'}}) + self.check('Web') + self.write('wrangler.json', {'assets': {'directory': './apps/site/build/client'}, 'main': 'server.js'}) + with self.assertRaises(ValueError): self.check('Web') + + def test_mobile_retired_targets_and_false_comment_declarations(self): + for text in ('kotlin { iosArm64() }', 'plugins { id("com.facebook.react") }', 'nativeDistributions {}'): + self.write('build.gradle.kts', text) + with self.assertRaises(ValueError): self.check('Mobile') + self.write('build.gradle.kts', '// no extra target\n') + self.write('app/build.gradle.kts', '// applicationId = "io.github.arcforges.mobile"\n// compose = true\n') + with self.assertRaisesRegex(ValueError, 'identity'): self.check('Mobile') + + def fixture(self): + subprocess.run(['git', 'init', '-q', str(self.root)], check=True) + subprocess.run(['git', '-C', str(self.root), 'remote', 'add', 'origin', 'https://github.com/ArcForges/DesktopPlatform.git'], check=True) + subprocess.run(['git', '-C', str(self.root), '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-qm', 'fixture'], check=True) + project = 'src/BuildingBlocks/Library/Library.csproj' + self.write(project, 'net10.0trueAGPL-3.0-onlyAGPL') + self.write('eng/policy/licence-boundary.json', {'schemaVersion': 1, 'repository': 'DesktopPlatform', + 'spdxLicense': 'AGPL-3.0-only', 'licenceBoundary': 'AGPL', 'projects': [{'path': project, 'kind': 'msbuild'}]}) + self.register() + return {'repository': 'DesktopPlatform', 'runtime': policy.RUNTIMES['DesktopPlatform'], + 'projects': [{'path': project, 'role': 'aot-library'}]} + + def register(self): + self.files.add('eng/provenance/files.json') + self.write('eng/provenance/files.json', {'schemaVersion': 1, 'repository': 'DesktopPlatform', + 'firstParty': sorted(self.files), 'reused': {}, 'artifacts': []}) + + def test_real_git_inventory_rejects_new_unassigned_source(self): + row = self.fixture() + self.assertEqual(policy.audit(self.root, row)['result'], 'passed') + self.write('src/BuildingBlocks/Library/Unexpected.cs', 'class Unexpected {}') + with self.assertRaisesRegex(ValueError, 'unassigned'): policy.audit(self.root, row) + + def test_aot_flag_does_not_allow_old_or_missing_framework(self): + for framework in ('', 'net8.0', 'net10.0;net8.0'): + self.write('Host.csproj', 'true' + framework + '') + with self.assertRaisesRegex(ValueError, 'framework'): + policy.check_managed(self.root, 'Host.csproj', 'aot-host', self.files) + + def test_retired_path_fails_even_if_inventory_claims_ownership(self): + row = self.fixture() + self.write('src/ArcChat/Program.cs', 'class Program {}') + self.register() + with self.assertRaisesRegex(ValueError, 'retired scaffold'): policy.audit(self.root, row) + + def test_new_project_fails_even_if_provenance_is_registered(self): + row = self.fixture() + self.write('src/BuildingBlocks/Other/Other.csproj', '') + self.register() + with self.assertRaises(ValueError): policy.audit(self.root, row) + + def test_missing_tracked_file_is_never_omitted(self): + row = self.fixture() + subprocess.run(['git', '-C', str(self.root), 'add', '.'], check=True) + (self.root / row['projects'][0]['path']).unlink() + with self.assertRaises(ValueError): policy.audit(self.root, row) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj b/src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj index bc97005..63f22e0 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj +++ b/src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj @@ -1,9 +1,9 @@