From 3587d29197e194e8ca3279ded1b0c5ba408dc017 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:39:50 +0000 Subject: [PATCH 01/14] docs(decisions): ADR-0013, build-time analyzers and the test stack Proposed. It names the test stack ADR-0004 D2 admits once slice 0's plan names it: xunit v3 on Microsoft.Testing.Platform, and CsCheck, chosen over FsCheck for having no dependencies (FsCheck brings FSharp.Core). It admits the analyzers ADR-0002 D3 requires: DecisionDriven.Analyzers for layering (DD0001), and PublicApiAnalyzers and BannedApiAnalyzers. It states that no analyzer assembly ships, and the one thing that does reach our assemblies: the DD generator's internal attribute types and one [assembly: ArchLayer]. D4 amends ADR-0004 D1's wording, as approved on #167: the register entry is the PackageVersion, which carries Adr= (and Scope= when test-only). ADR-0004 is not edited (ADR-0001 D3). The consequence is stated: every DD rule is on as an error, and the first public interface needs a decision set in the package's format. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- ...013-build-time-analyzers-and-test-stack.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md diff --git a/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md b/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md new file mode 100644 index 00000000..800cf7b2 --- /dev/null +++ b/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md @@ -0,0 +1,46 @@ +# ADR-0013: Build-time analyzers and the test stack + +**Status:** Proposed +**Date:** 2026-11-03 +**Definition:** chapter 7, Constraints; D1.25 +**Amends:** ADR-0004 D1 (its wording only, D4 below) + +## Context + +ADR-0002 D3 requires layering to be enforced by an analyzer that fails the build, and every library to have a public API baseline. ADR-0003 D4 bans reflection in shipped code. ADR-0004 D2 admits Varve, and "the test framework and the property-testing library … once named in slice 0's plan". Slice 0 (D1.25, #167) named them, and needs the analyzers, which no ADR has yet admitted. + +Every package here was resolved from nuget.org on 2026-11-03. + +Varve consumes `DecisionDriven.Analyzers` (source `Hafeok/decision-driven-analyzers`) for its layering. Slice 0's plan checked its `0.1.0-preview.10` package against two questions: + +- **Does its layering rule apply to a family other than Varve's?** Yes. DD0001 reads `ArchFamily=F` and `ArchLayer=n` from the project. It checks every referenced assembly whose name begins `F.`, reading that assembly's `[assembly: ArchLayer]` from metadata, and requires it strictly below `n`. Nothing in the rule names Varve. +- **Is it AOT-neutral?** Yes. The package holds `analyzers/dotnet/cs/*.dll` (netstandard2.0) and `build/` props and targets, and no `lib/`. It is marked `developmentDependency`. + +## Decision + +- **D1. The test stack.** Both packages are test-only: `Scope="tests/"` in the register, referenced only by the `*.Tests` projects, and never published with AOT. ADR-0004 D2 admits them, so their register entries cite `Adr="0004"`. + - **xunit v3 (`xunit.v3` 4.0.2)**, on Microsoft.Testing.Platform. `global.json` names the platform as the test runner, and `xunit.v3` 4.x brings its v2 adapter, `xunit.v3.mtp-v2`. This is what Varve uses. + - **CsCheck (5.0.1)** for property tests. It is Apache-2.0 and has no dependencies of its own. +- **D2. The analyzers.** These are admitted by this ADR, and their entries cite `Adr="0013"`. + - **`DecisionDriven.Analyzers` (0.1.0-preview.10)**, for ADR-0002 D3's layering rule: DD0001 under `ArchFamily=ProductCli`, with every project's layer declared and `dd_require_layer = true`. It is referenced from `Directory.Build.props` by every project, with `PrivateAssets="all" IncludeAssets="analyzers;build"`. It is a preview, so a bump is a register change, made in a PR. + - **`Microsoft.CodeAnalysis.PublicApiAnalyzers` (5.6.0)**, for ADR-0002 D3's public API baselines. Every library carries `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`, and the build fails on a library without them, since the analyzer is silent when the files are absent. + - **`Microsoft.CodeAnalysis.BannedApiAnalyzers` (5.6.0)**, for ADR-0003 D4. `eng/BannedSymbols.txt` bans, in every project under `src/`, the reflection that defeats trimming and Native AOT: `System.Reflection.Emit`, `Type.GetType` by name, reflective `Activator.CreateInstance`, and `Assembly.Load`, `LoadFrom` and `LoadFile`. Each entry cites ADR-0003 D4. +- **D3. Nothing ships.** No analyzer assembly reaches the binary or any output: every analyzer reference is `PrivateAssets="all"`, and none of the three packages has a `lib/` asset. The analyzers do add bytes to our assemblies, and only these: the `DecisionDriven.Analyzers` generator compiles internal attribute types into each assembly it analyzes (`DecisionDriven.ArchLayerAttribute` and the decision-citation attributes), and one `[assembly: ArchLayer(n)]`. They are plain attributes, read by no code of ours at run time, with no reflection and no effect on AOT. +- **D4. The register entry is the `PackageVersion`.** Under central package management, the register is `Directory.Packages.props`, Varve's form, which ADR-0004 D1 adopts. A `PackageReference` there carries no version. + - This ADR therefore **amends the wording of ADR-0004 D1**, "Every `PackageReference` carries `Adr=`". It now reads: every `PackageVersion` in `Directory.Packages.props` carries `Adr=` naming the ADR that admits it, and a test-only entry also carries `Scope=`. The build fails on an entry without `Adr=`, on an `Adr=` that names no ADR in `docs/decisions/`, and on a reference from a project outside the entry's `Scope=`. + - ADR-0004 is cited from here on as amended by this ADR. Its file is not edited (ADR-0001 D3). + +## Alternatives considered + +- **FsCheck for property tests.** Rejected. FsCheck 3.6.0 depends on `FSharp.Core`, which puts a second language's runtime library in every test closure for no capability CsCheck lacks. +- **No property-testing library in slice 0.** Rejected: ADR-0004 D2 names one in the starting register, and slice 0 has a property to test (every argument but `--version` exits 2). +- **A local layering analyzer**, the fallback ADR-0002 D3 allows. Rejected: the package's DD0001 applies to our family unchanged, and a local analyzer is code to keep for no gain. +- **Enabling only DD0001 of `DecisionDriven.Analyzers`.** Not possible without lowering the other rules' severity, which is a suppression: DD0008 reports it, and ADR-0007 D6 forbids it. +- **Requiring `Adr=` on each `PackageReference` as well.** Rejected: it would repeat on every reference what the register already states once, and two copies of one fact can disagree. +- **Compiling the layering probes in process.** Rejected: that needs `Microsoft.CodeAnalysis.CSharp` as one more test package, and an analyzer configuration built by hand to imitate MSBuild. The probes are built with `dotnet build` under the real build files instead, which tests the wiring as well as the rule. + +## Consequences + +- **Every rule of `DecisionDriven.Analyzers` is on as an error**, not only DD0001. That includes, among others, `[Contract]` on every public interface (DD0009), the contract type vocabulary (DD0010), no naked primitives on contract and model surfaces (DD0013), and no mutable static state (DD0004). This is ADR-0002 D3's intent. None fires on the slice 0 skeleton. **The first public interface needs a decision set in the package's format** (YAML front matter in a markdown file, read through `DdLedgerDirectory`), so that `[Contract]` can cite it. That is a question for the slice that adds the interface. +- The register is a gate in the build, not a convention. Its failing cases are permanent tests (`BuildPolicyTests`), built on every run. +- A new package needs an ADR and a `PackageVersion` entry with `Adr=`, and `Scope=` when it is test-only. `AGENTS.md` states the rule. From 303bc8c8caa48d39b30f0371ae296f5bd38870fd Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:39:50 +0000 Subject: [PATCH 02/14] build: the build policy and the package register The .NET build policy of ADR-0002, ADR-0003 and ADR-0004 as amended by ADR-0013, after Varve's pattern, with nothing copied that names Varve's packages or ADRs. - global.json: SDK 10.0.401, rollForward latestFeature, and Microsoft.Testing.Platform as the test runner. - Directory.Build.props: net10.0, nullable, warnings as errors, latest-recommended analysis, code style in the build, deterministic builds, ArchFamily=ProductCli, the product version 0.8.0-preview.2, and the supported runtime identifiers (ADR-0003 D5). The root's .editorconfig and .globalconfig are named for every project: the SDK's discovery starts from a project's source files, and a library with none would build without them. - Directory.Build.targets: every src/ library IsAotCompatible and IsTrimmable with the AOT, trim and single-file analyzers, a public API baseline required (PCLI0004), the banned-symbol list on all product code, the host's source commit stamped (PCLI0005), and the register checks: PCLI0001 (no Adr=), PCLI0002 (Adr= names no ADR), PCLI0003 (a reference from outside Scope=). - Directory.Packages.props, the register: Varve.Rdf and Varve.Iri 0.1.0-preview.15 (Adr 0004); Varve.Turtle 0.1.0-preview.15, xunit.v3 4.0.2 and CsCheck 5.0.1, test-only (Adr 0004, Scope tests/); DecisionDriven.Analyzers 0.1.0-preview.10, and PublicApiAnalyzers and BannedApiAnalyzers 5.6.0 (Adr 0013). Resolved from nuget.org on 2026-11-03. - .globalconfig: dd_require_layer, so no ProductCli project stays unplaced. - .editorconfig: the IL trim and AOT diagnostics at error; nothing lowered. - .gitattributes: LF everywhere, so the gate scripts run under bash on the Windows runner; the vendored fixtures are never converted. - nuget.config: nuget.org only, with source mapping. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .editorconfig | 49 ++++++++++++++ .gitattributes | 6 ++ .gitignore | 5 ++ .globalconfig | 6 ++ Directory.Build.props | 91 ++++++++++++++++++++++++++ Directory.Build.targets | 134 +++++++++++++++++++++++++++++++++++++++ Directory.Packages.props | 59 +++++++++++++++++ eng/BannedSymbols.txt | 30 +++++++++ global.json | 10 +++ nuget.config | 17 +++++ 10 files changed, 407 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .globalconfig create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 eng/BannedSymbols.txt create mode 100644 global.json create mode 100644 nuget.config diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d3ec9bd8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,49 @@ +# The style and analyzer severities of the build (ADR-0002 D3, ADR-0003 D2). +# Nothing here lowers a rule's severity (ADR-0007 D6). +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{csproj,props,targets,slnx,config,json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Usings are explicit (ImplicitUsings disable), and an unused one is an error. +dotnet_diagnostic.IDE0005.severity = warning +csharp_style_namespace_declarations = file_scoped:warning +dotnet_diagnostic.IDE0161.severity = warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true + +# ADR-0003 D2: the trim, AOT and single-file analyzers' diagnostics are +# errors, whatever their default severity. +dotnet_diagnostic.IL2026.severity = error +dotnet_diagnostic.IL2046.severity = error +dotnet_diagnostic.IL2050.severity = error +dotnet_diagnostic.IL2055.severity = error +dotnet_diagnostic.IL2057.severity = error +dotnet_diagnostic.IL2058.severity = error +dotnet_diagnostic.IL2059.severity = error +dotnet_diagnostic.IL2060.severity = error +dotnet_diagnostic.IL2062.severity = error +dotnet_diagnostic.IL2067.severity = error +dotnet_diagnostic.IL2070.severity = error +dotnet_diagnostic.IL2072.severity = error +dotnet_diagnostic.IL2075.severity = error +dotnet_diagnostic.IL2087.severity = error +dotnet_diagnostic.IL2091.severity = error +dotnet_diagnostic.IL3000.severity = error +dotnet_diagnostic.IL3001.severity = error +dotnet_diagnostic.IL3002.severity = error +dotnet_diagnostic.IL3050.severity = error +dotnet_diagnostic.IL3051.severity = error +dotnet_diagnostic.IL3053.severity = error diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6ac59892 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Line feeds in every checkout, Windows included: the gate scripts run under +# bash on every runner, and a build's bytes do not depend on the platform. +* text=auto eol=lf + +# The vendored fixtures are compared by sha256, so git never touches their bytes. +tests/fixtures/ddd/** -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f38dcbcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +artifacts/ +*.user +.vs/ diff --git a/.globalconfig b/.globalconfig new file mode 100644 index 00000000..42647054 --- /dev/null +++ b/.globalconfig @@ -0,0 +1,6 @@ +# Analyzer options for the whole compilation (ADR-0002 D3, ADR-0013 D2). +is_global = true + +# DD0001: a ProductCli.* project that declares no ArchLayer is itself an error, +# so no project can stay unplaced in the layer table. +dd_require_layer = true diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..e9b8fd39 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,91 @@ + + + + + + $([MSBuild]::EnsureTrailingSlash('$(MSBuildThisFileDirectory)')) + + + + net10.0 + latest + enable + + disable + + + + true + latest-recommended + true + true + + + + + true + true + true + + + + + false + false + + + + + + + + + + + false + + + + + 0.8.0-preview.2 + + false + + + + + linux-x64;linux-arm64;osx-arm64;win-x64 + + + + + ProductCli + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..52f71f66 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,134 @@ + + + + + <_ProductCliPath>$([MSBuild]::MakeRelative('$(ProductCliRoot)', '$(MSBuildProjectDirectory)').Replace('\', '/').TrimEnd('/'))/ + src/$(MSBuildProjectName)/ + $(_ProductCliPath) + true + true + true + + + + + true + true + + + + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ProductCliAdrFile Include="$(ProductCliRoot)docs/decisions/ADR-*.md" /> + <_ProductCliAdrNumber Include="$([System.String]::Copy('%(_ProductCliAdrFile.Filename)').Substring(4, 4))" /> + + <_ProductCliEntry Include="@(PackageVersion)" PackageId="%(Identity)" DefinedIn="%(DefiningProjectFullPath)" /> + <_ProductCliCitation Include="@(_ProductCliEntry->'%(Adr)')" Condition="'%(_ProductCliEntry.Adr)' != ''" /> + <_ProductCliDangling Include="@(_ProductCliCitation)" Exclude="@(_ProductCliAdrNumber)" /> + + <_ProductCliScoped Include="@(PackageVersion)" Condition="'%(PackageVersion.Scope)' != ''" /> + <_ProductCliScopedUnused Include="@(_ProductCliScoped)" Exclude="@(PackageReference)" /> + <_ProductCliScopedUsed Include="@(_ProductCliScoped)" Exclude="@(_ProductCliScopedUnused)" /> + <_ProductCliScopedUsed Update="@(_ProductCliScopedUsed)" InScope="$(ProductCliPolicyPath.StartsWith('%(Scope)'))" /> + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..7f279851 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,59 @@ + + + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/BannedSymbols.txt b/eng/BannedSymbols.txt new file mode 100644 index 00000000..8b1b1058 --- /dev/null +++ b/eng/BannedSymbols.txt @@ -0,0 +1,30 @@ +// Banned symbols, for all product code (src/). Directory.Build.targets passes +// this file to Microsoft.CodeAnalysis.BannedApiAnalyzers (ADR-0013 D2). +// +// ADR-0003 D4: no reflection, Reflection.Emit or dynamic in shipped code. Each +// entry below defeats trimming or does not exist under Native AOT, so it is +// banned outright rather than left for the trim analyzer to catch at a call +// site. `dynamic` is DD0020's (ADR-0013 D2). +// +// Format: one documentation ID per line, ';', then the message shown at the +// call site. Every message cites the decision that bans it. + +// Runtime code generation: none under Native AOT. +N:System.Reflection.Emit; Native AOT has no Reflection.Emit; generate the code at compile time. ADR-0003 D4. + +// Types named in strings: the trimmer cannot see the reference. +M:System.Type.GetType(System.String); A type named in a string defeats trimming; name the type in code. ADR-0003 D4. +M:System.Type.GetType(System.String,System.Boolean); A type named in a string defeats trimming; name the type in code. ADR-0003 D4. +M:System.Type.GetType(System.String,System.Boolean,System.Boolean); A type named in a string defeats trimming; name the type in code. ADR-0003 D4. + +// Reflective activation. +M:System.Activator.CreateInstance(System.Type); Reflective activation defeats trimming; call a constructor or a factory. ADR-0003 D4. +M:System.Activator.CreateInstance(System.Type,System.Boolean); Reflective activation defeats trimming; call a constructor or a factory. ADR-0003 D4. +M:System.Activator.CreateInstance(System.Type,System.Object[]); Reflective activation defeats trimming; call a constructor or a factory. ADR-0003 D4. +M:System.Activator.CreateInstance(System.String,System.String); Reflective activation defeats trimming; call a constructor or a factory. ADR-0003 D4. + +// Loading assemblies at run time: none under Native AOT. +M:System.Reflection.Assembly.Load(System.String); Native AOT loads no assembly at run time. ADR-0003 D4. +M:System.Reflection.Assembly.Load(System.Byte[]); Native AOT loads no assembly at run time. ADR-0003 D4. +M:System.Reflection.Assembly.LoadFrom(System.String); Native AOT loads no assembly at run time. ADR-0003 D4. +M:System.Reflection.Assembly.LoadFile(System.String); Native AOT loads no assembly at run time. ADR-0003 D4. diff --git a/global.json b/global.json new file mode 100644 index 00000000..9773a5f5 --- /dev/null +++ b/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestFeature", + "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..6f44751a --- /dev/null +++ b/nuget.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + From 83827833e12c372800e2b9c187dab42277c1ee5e Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:40:03 +0000 Subject: [PATCH 03/14] ci(gates): no-toolchain allows exactly one toolchain, .NET The rule that replaces "no Rust, Node or Python toolchain file at the root", as the plan on #167 states it. The check fails when: 1. a Rust, Node or Python toolchain file (the same list), or a requirements*.txt, is anywhere in the tree outside .git/, bin/, obj/ and artifacts/, not only at the root; 2. the root has no global.json, or its sdk.version is not 10.0.401, its sdk.rollForward not latestFeature, or its test.runner not Microsoft.Testing.Platform; 3. a global.json exists below the root. The job keeps its name, so a required-check setting still matches; its step is retitled. selftest's clean case now carries the pin, and it gains plants for a package.json under src/, a missing pin, another SDK version, another rollForward, no test runner and a second global.json, and a clean case showing bin/, obj/ and .git/ are not the tree. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .github/workflows/gates.yml | 2 +- eng/gates/no-toolchain.sh | 58 ++++++++++++++++++++++++++++++------- eng/gates/selftest.sh | 57 +++++++++++++++++++++++++++++++----- 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 36f2a391..5c81bd0c 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -63,7 +63,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: No Rust, Node or Python toolchain file at the root + - name: The only toolchain is .NET, pinned in global.json run: eng/gates/no-toolchain.sh . selftest: diff --git a/eng/gates/no-toolchain.sh b/eng/gates/no-toolchain.sh index 36b19cee..2d5ec783 100755 --- a/eng/gates/no-toolchain.sh +++ b/eng/gates/no-toolchain.sh @@ -1,10 +1,21 @@ #!/usr/bin/env bash -# The toolchain gate: no Rust, Node or Python toolchain file exists at the repository root (chapter 7). +# The toolchain gate: the only toolchain is .NET, pinned at the root (chapter 7; ADR-0013, #167). +# +# Fails when: +# 1. a Rust, Node or Python toolchain file, or a requirements*.txt, is +# anywhere in the tree (outside .git/, bin/, obj/ and artifacts/); +# 2. the root has no global.json, or its sdk.version is not 10.0.401, its +# sdk.rollForward is not latestFeature, or its test.runner is not +# Microsoft.Testing.Platform; +# 3. a global.json exists below the root, a second pin. # # Usage: eng/gates/no-toolchain.sh [root] (default: .) set -euo pipefail root=${1:-.} +sdk_version=10.0.401 +roll_forward=latestFeature +test_runner=Microsoft.Testing.Platform rust=(Cargo.toml Cargo.lock rust-toolchain rust-toolchain.toml .cargo) node=(package.json package-lock.json npm-shrinkwrap.json yarn.lock .yarnrc.yml @@ -15,20 +26,45 @@ python=(pyproject.toml setup.py setup.cfg Pipfile Pipfile.lock poetry.lock uv.lo shared=(.tool-versions) failed=0 + +# 1. No other toolchain, anywhere. +names=() for f in "${rust[@]}" "${node[@]}" "${python[@]}" "${shared[@]}"; do - if [ -e "$root/$f" ]; then - echo "no-toolchain: $f is at the root" - failed=1 - fi + names+=(-o -name "$f") done -shopt -s nullglob -for f in "$root"/requirements*.txt; do - echo "no-toolchain: ${f#"$root"/} is at the root" +while IFS= read -r -d '' path; do + echo "no-toolchain: ${path#"$root"/} is a non-.NET toolchain file" failed=1 -done -shopt -u nullglob +done < <(find "$root" \( -name .git -o -name bin -o -name obj -o -name artifacts \) -prune -o \ + \( -name 'requirements*.txt' "${names[@]}" \) -print0 | sort -z) + +# 2. The .NET pin, at the root. +pin="$root/global.json" +if [ ! -f "$pin" ]; then + echo "no-toolchain: global.json is not at the root" + failed=1 +else + check() { # check + local actual + actual=$(jq -r "$1 // \"(absent)\"" "$pin" 2>/dev/null || echo "(unreadable)") + if [ "$actual" != "$2" ]; then + echo "no-toolchain: global.json $1 is $actual, not $2" + failed=1 + fi + } + check .sdk.version "$sdk_version" + check .sdk.rollForward "$roll_forward" + check .test.runner "$test_runner" +fi + +# 3. One pin only. +while IFS= read -r -d '' path; do + echo "no-toolchain: ${path#"$root"/} is a second global.json" + failed=1 +done < <(find "$root" \( -name .git -o -name bin -o -name obj -o -name artifacts \) -prune -o \ + -name global.json ! -path "$root/global.json" -print0 | sort -z) if [ "$failed" -ne 0 ]; then exit 1 fi -echo "no-toolchain: no Rust, Node or Python toolchain file at the root" +echo "no-toolchain: the only toolchain is .NET, SDK $sdk_version pinned in global.json" diff --git a/eng/gates/selftest.sh b/eng/gates/selftest.sh index 1874a1c6..6b2da95c 100755 --- a/eng/gates/selftest.sh +++ b/eng/gates/selftest.sh @@ -177,17 +177,58 @@ expect_fail "adr-text: an ADR file missing" "ADR-0012: expected one file, found # --- no-toolchain ------------------------------------------------------------ -mkdir -p "$work/root-clean" -cp -R "$repo/docs" "$work/root-clean/" 2>/dev/null || true -expect_pass "no-toolchain" "$gates/no-toolchain.sh" "$work/root-clean" +# A root with the .NET pin and a project tree, which every plant starts from. +toolchain_root() { # toolchain_root + local r="$work/$1" + rm -rf "$r" + mkdir -p "$r/src/ProductCli.Probe" "$r/tests" + cp "$repo/global.json" "$r/global.json" + : > "$r/src/ProductCli.Probe/ProductCli.Probe.csproj" + echo "$r" +} + +r=$(toolchain_root root-clean) +expect_pass "no-toolchain" "$gates/no-toolchain.sh" "$r" + +# bin/, obj/ and .git/ are not the tree: a file in them is not a toolchain. +r=$(toolchain_root root-ignored) +mkdir -p "$r/src/ProductCli.Probe/obj" "$r/.git" +: > "$r/src/ProductCli.Probe/obj/package.json" +: > "$r/.git/global.json" +expect_pass "no-toolchain: bin, obj and .git are not the tree" "$gates/no-toolchain.sh" "$r" + for f in Cargo.toml rust-toolchain.toml package.json pnpm-lock.yaml pyproject.toml requirements.txt .python-version; do - r="$work/root-$f" - mkdir -p "$r" + r=$(toolchain_root "root-$f") : > "$r/$f" - expect_fail "no-toolchain: $f" "no-toolchain: $f is at the root" "$gates/no-toolchain.sh" "$r" + expect_fail "no-toolchain: $f" "no-toolchain: $f is a non-.NET toolchain file" "$gates/no-toolchain.sh" "$r" done -mkdir -p "$work/root-cargo-dir/.cargo" -expect_fail "no-toolchain: .cargo/" "no-toolchain: .cargo is at the root" "$gates/no-toolchain.sh" "$work/root-cargo-dir" +r=$(toolchain_root root-cargo-dir) +mkdir -p "$r/.cargo" +expect_fail "no-toolchain: .cargo/" "no-toolchain: .cargo is a non-.NET toolchain file" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-nested-node) +: > "$r/src/ProductCli.Probe/package.json" +expect_fail "no-toolchain: package.json under src/" "no-toolchain: src/ProductCli.Probe/package.json is a non-.NET toolchain file" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-no-pin) +rm "$r/global.json" +expect_fail "no-toolchain: no global.json" "no-toolchain: global.json is not at the root" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-wrong-sdk) +jq '.sdk.version = "9.0.100"' "$repo/global.json" > "$r/global.json" +expect_fail "no-toolchain: another SDK version" "global.json .sdk.version is 9.0.100, not 10.0.401" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-wrong-rollforward) +jq '.sdk.rollForward = "latestMajor"' "$repo/global.json" > "$r/global.json" +expect_fail "no-toolchain: another rollForward" "global.json .sdk.rollForward is latestMajor, not latestFeature" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-no-runner) +jq 'del(.test)' "$repo/global.json" > "$r/global.json" +expect_fail "no-toolchain: no test runner" "global.json .test.runner is (absent), not Microsoft.Testing.Platform" "$gates/no-toolchain.sh" "$r" + +r=$(toolchain_root root-second-pin) +cp "$repo/global.json" "$r/tests/global.json" +expect_fail "no-toolchain: a second global.json" "no-toolchain: tests/global.json is a second global.json" "$gates/no-toolchain.sh" "$r" if [ "$failed" -ne 0 ]; then echo "selftest: a check did not behave as its plant requires" From 748673f20b7ec2350aa78371d6f769a5613f2336 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:40:21 +0000 Subject: [PATCH 04/14] feat: the seven layer libraries, with their API baselines Layers 0 to 6 of ADR-0002 D1: ProductCli.Vocabulary, Canonical, Shapes, Draft, Simulation, Runner and Session. Each declares its ArchLayer and references only the project directly below it, so the chain is ADR-0002 D1's diagram. ProductCli.Canonical references Varve.Rdf, the one runtime package of slice 0. Each is empty apart from its public API baseline (ADR-0002, Consequences): PublicAPI.Shipped.txt holds "#nullable enable" and PublicAPI.Unshipped.txt nothing. ProductCli.slnx lists them. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- ProductCli.slnx | 11 +++++++++++ .../ProductCli.Canonical.csproj | 16 ++++++++++++++++ src/ProductCli.Canonical/PublicAPI.Shipped.txt | 1 + src/ProductCli.Canonical/PublicAPI.Unshipped.txt | 0 src/ProductCli.Draft/ProductCli.Draft.csproj | 12 ++++++++++++ src/ProductCli.Draft/PublicAPI.Shipped.txt | 1 + src/ProductCli.Draft/PublicAPI.Unshipped.txt | 0 src/ProductCli.Runner/ProductCli.Runner.csproj | 12 ++++++++++++ src/ProductCli.Runner/PublicAPI.Shipped.txt | 1 + src/ProductCli.Runner/PublicAPI.Unshipped.txt | 0 src/ProductCli.Session/ProductCli.Session.csproj | 12 ++++++++++++ src/ProductCli.Session/PublicAPI.Shipped.txt | 1 + src/ProductCli.Session/PublicAPI.Unshipped.txt | 0 src/ProductCli.Shapes/ProductCli.Shapes.csproj | 12 ++++++++++++ src/ProductCli.Shapes/PublicAPI.Shipped.txt | 1 + src/ProductCli.Shapes/PublicAPI.Unshipped.txt | 0 .../ProductCli.Simulation.csproj | 12 ++++++++++++ src/ProductCli.Simulation/PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 0 .../ProductCli.Vocabulary.csproj | 8 ++++++++ src/ProductCli.Vocabulary/PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 0 22 files changed, 102 insertions(+) create mode 100644 ProductCli.slnx create mode 100644 src/ProductCli.Canonical/ProductCli.Canonical.csproj create mode 100644 src/ProductCli.Canonical/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Canonical/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Draft/ProductCli.Draft.csproj create mode 100644 src/ProductCli.Draft/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Draft/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Runner/ProductCli.Runner.csproj create mode 100644 src/ProductCli.Runner/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Runner/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Session/ProductCli.Session.csproj create mode 100644 src/ProductCli.Session/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Session/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Shapes/ProductCli.Shapes.csproj create mode 100644 src/ProductCli.Shapes/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Shapes/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Simulation/ProductCli.Simulation.csproj create mode 100644 src/ProductCli.Simulation/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Simulation/PublicAPI.Unshipped.txt create mode 100644 src/ProductCli.Vocabulary/ProductCli.Vocabulary.csproj create mode 100644 src/ProductCli.Vocabulary/PublicAPI.Shipped.txt create mode 100644 src/ProductCli.Vocabulary/PublicAPI.Unshipped.txt diff --git a/ProductCli.slnx b/ProductCli.slnx new file mode 100644 index 00000000..6cff7800 --- /dev/null +++ b/ProductCli.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ProductCli.Canonical/ProductCli.Canonical.csproj b/src/ProductCli.Canonical/ProductCli.Canonical.csproj new file mode 100644 index 00000000..60d50e2c --- /dev/null +++ b/src/ProductCli.Canonical/ProductCli.Canonical.csproj @@ -0,0 +1,16 @@ + + + + + 1 + + + + + + + + + + + diff --git a/src/ProductCli.Canonical/PublicAPI.Shipped.txt b/src/ProductCli.Canonical/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Canonical/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Canonical/PublicAPI.Unshipped.txt b/src/ProductCli.Canonical/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Draft/ProductCli.Draft.csproj b/src/ProductCli.Draft/ProductCli.Draft.csproj new file mode 100644 index 00000000..bd27bd71 --- /dev/null +++ b/src/ProductCli.Draft/ProductCli.Draft.csproj @@ -0,0 +1,12 @@ + + + + + 3 + + + + + + + diff --git a/src/ProductCli.Draft/PublicAPI.Shipped.txt b/src/ProductCli.Draft/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Draft/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Draft/PublicAPI.Unshipped.txt b/src/ProductCli.Draft/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Runner/ProductCli.Runner.csproj b/src/ProductCli.Runner/ProductCli.Runner.csproj new file mode 100644 index 00000000..3e56b517 --- /dev/null +++ b/src/ProductCli.Runner/ProductCli.Runner.csproj @@ -0,0 +1,12 @@ + + + + + 5 + + + + + + + diff --git a/src/ProductCli.Runner/PublicAPI.Shipped.txt b/src/ProductCli.Runner/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Runner/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Runner/PublicAPI.Unshipped.txt b/src/ProductCli.Runner/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Session/ProductCli.Session.csproj b/src/ProductCli.Session/ProductCli.Session.csproj new file mode 100644 index 00000000..165d8e30 --- /dev/null +++ b/src/ProductCli.Session/ProductCli.Session.csproj @@ -0,0 +1,12 @@ + + + + + 6 + + + + + + + diff --git a/src/ProductCli.Session/PublicAPI.Shipped.txt b/src/ProductCli.Session/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Session/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Session/PublicAPI.Unshipped.txt b/src/ProductCli.Session/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Shapes/ProductCli.Shapes.csproj b/src/ProductCli.Shapes/ProductCli.Shapes.csproj new file mode 100644 index 00000000..e819b747 --- /dev/null +++ b/src/ProductCli.Shapes/ProductCli.Shapes.csproj @@ -0,0 +1,12 @@ + + + + + 2 + + + + + + + diff --git a/src/ProductCli.Shapes/PublicAPI.Shipped.txt b/src/ProductCli.Shapes/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Shapes/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Shapes/PublicAPI.Unshipped.txt b/src/ProductCli.Shapes/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Simulation/ProductCli.Simulation.csproj b/src/ProductCli.Simulation/ProductCli.Simulation.csproj new file mode 100644 index 00000000..3f8f0219 --- /dev/null +++ b/src/ProductCli.Simulation/ProductCli.Simulation.csproj @@ -0,0 +1,12 @@ + + + + + 4 + + + + + + + diff --git a/src/ProductCli.Simulation/PublicAPI.Shipped.txt b/src/ProductCli.Simulation/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Simulation/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Simulation/PublicAPI.Unshipped.txt b/src/ProductCli.Simulation/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/ProductCli.Vocabulary/ProductCli.Vocabulary.csproj b/src/ProductCli.Vocabulary/ProductCli.Vocabulary.csproj new file mode 100644 index 00000000..a2acf0d9 --- /dev/null +++ b/src/ProductCli.Vocabulary/ProductCli.Vocabulary.csproj @@ -0,0 +1,8 @@ + + + + + 0 + + + diff --git a/src/ProductCli.Vocabulary/PublicAPI.Shipped.txt b/src/ProductCli.Vocabulary/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/ProductCli.Vocabulary/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ProductCli.Vocabulary/PublicAPI.Unshipped.txt b/src/ProductCli.Vocabulary/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b From 191b7904d01f0ad679ee709b9284a06abe7e47e7 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:40:21 +0000 Subject: [PATCH 05/14] feat(host): product --version, and exit 2 for every verb not yet built ProductCli.Host, layer 7 and the composition root, publishes one Native AOT executable, product (ADR-0003 D1). Its assembly keeps the family name, which DD0001 reads; the published binary is renamed after ILC writes it. - product --version writes "product (commit )" and a line feed to stdout as ASCII bytes, the same on every platform, and exits 0. Both values are read from the assembly's metadata: the informational version, and the SourceCommit the build stamps from SourceRevisionId. - Every other argument vector exits 2 with one line on stderr. A verb of chapter 3 names the step of the order of work it arrives in (the table approved on #167); anything else is named as not a verb, escaped to printable ASCII so the line stays one line. The line is not a format. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- ProductCli.slnx | 1 + src/ProductCli.Host/ProductCli.Host.csproj | 43 +++++++++ src/ProductCli.Host/Program.cs | 106 +++++++++++++++++++++ src/ProductCli.Host/Verbs.cs | 20 ++++ 4 files changed, 170 insertions(+) create mode 100644 src/ProductCli.Host/ProductCli.Host.csproj create mode 100644 src/ProductCli.Host/Program.cs create mode 100644 src/ProductCli.Host/Verbs.cs diff --git a/ProductCli.slnx b/ProductCli.slnx index 6cff7800..97582ac6 100644 --- a/ProductCli.slnx +++ b/ProductCli.slnx @@ -7,5 +7,6 @@ + diff --git a/src/ProductCli.Host/ProductCli.Host.csproj b/src/ProductCli.Host/ProductCli.Host.csproj new file mode 100644 index 00000000..79f53868 --- /dev/null +++ b/src/ProductCli.Host/ProductCli.Host.csproj @@ -0,0 +1,43 @@ + + + + + 7 + true + Exe + true + true + true + + + + + + + + + + + + + + + + false + false + .none + + + diff --git a/src/ProductCli.Host/Program.cs b/src/ProductCli.Host/Program.cs new file mode 100644 index 00000000..8045bd05 --- /dev/null +++ b/src/ProductCli.Host/Program.cs @@ -0,0 +1,106 @@ +using System; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text; + +namespace ProductCli.Host; + +/// +/// The entry point of `product`. In slice 0 (D1.25) its only behaviour is +/// `product --version`; every other argument vector exits 2 and names the step +/// of the order of work its verb arrives in. +/// +internal static class Program +{ + private const int Success = 0; + private const int NotBuilt = 2; + + private static int Main(string[] args) + { + if (args.Length == 1 && args[0] == "--version") + { + Write(Console.OpenStandardOutput(), VersionLine()); + return Success; + } + + Write(Console.OpenStandardError(), Refusal(args)); + return NotBuilt; + } + + /// + /// product <version> (commit <sha>) and a line feed, read + /// from the metadata the build stamps into this assembly. + /// + private static string VersionLine() + { + Assembly assembly = typeof(Program).Assembly; + string version = assembly.GetCustomAttribute()?.InformationalVersion + ?? throw new InvalidOperationException("The build stamped no informational version."); + + string? commit = null; + foreach (AssemblyMetadataAttribute metadata in assembly.GetCustomAttributes()) + { + if (metadata.Key == "SourceCommit") + { + commit = metadata.Value; + } + } + + return string.IsNullOrEmpty(commit) + ? throw new InvalidOperationException("The build stamped no source commit.") + : "product " + version + " (commit " + commit + ")\n"; + } + + /// The one line, with its line feed, for an argument vector this build does not serve. + private static string Refusal(string[] args) + { + if (args.Length == 0) + { + return "product: no verb given; this build has only --version (step 0 of the order of work, D1.25)\n"; + } + + string first = args[0]; + if (first == "--version") + { + return "product: --version takes no arguments\n"; + } + + int step = Verbs.StepOf(first); + return step == 0 + ? "product: '" + Printable(first) + "' is not a verb\n" + : "product: '" + first + "' arrives in step " + step.ToString(CultureInfo.InvariantCulture) + " of the order of work (D1.25)\n"; + } + + /// + /// The argument as printable ASCII: every other character is written as + /// \uXXXX, so the message stays one line whatever was typed. + /// + private static string Printable(string argument) + { + StringBuilder text = new(argument.Length); + foreach (char c in argument) + { + if (c is >= ' ' and <= '~') + { + text.Append(c); + } + else + { + text.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + } + + return text.ToString(); + } + + /// Writes the text as ASCII bytes, so the bytes are the same on every platform and console. + private static void Write(Stream stream, string text) + { + using (stream) + { + stream.Write(Encoding.ASCII.GetBytes(text)); + stream.Flush(); + } + } +} diff --git a/src/ProductCli.Host/Verbs.cs b/src/ProductCli.Host/Verbs.cs new file mode 100644 index 00000000..28df3b97 --- /dev/null +++ b/src/ProductCli.Host/Verbs.cs @@ -0,0 +1,20 @@ +namespace ProductCli.Host; + +/// +/// Where each verb of the definition's chapter 3 arrives in the order of work +/// (D1.25). The message that uses this is not a format: it is for a person, +/// and it changes as verbs are built. +/// +internal static class Verbs +{ + /// The step a verb arrives in, or 0 for an argument that is not a verb. + internal static int StepOf(string verb) => verb switch + { + "init" or "product" or "domain" or "diff" => 4, + "proposal" or "view" or "mcp" => 5, + "decider" or "projector" or "primitive" => 6, + "scenario" or "conformance" => 7, + "question" => 8, + _ => 0, + }; +} From fb989e3af9c999ace2e470d0764cd553627d6002 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:40:37 +0000 Subject: [PATCH 06/14] test: the layering probes, one per layer ADR-0002 D3 and its consequence for slice 0: one test per layer proving the analyzer refuses an upward reference. The probe, under tests/fixtures/ layering/, is built with dotnet build under the repository's real build files (approved on #167): Directory.Build.*, .globalconfig and the analyzer package's build props, so the tests prove the wiring as well as the rule. The tests read DD0001 in the compiler's output (ADR-0006). - Layers 0 to 6: a probe at layer L referencing the layer L+1 project fails with "DD0001: 'ProductCli.Probe' is at layer L and references 'ProductCli.', which is at layer L+1". - Layer 7: nothing is above it, so its refusal is the same-layer case, a probe at 7 referencing ProductCli.Host. - An unplaced probe fails with DD0001 (dd_require_layer). - The positive probe: layer 1 referencing ProductCli.Vocabulary builds, with no DD0001. A probe builds into its own temporary directory, against the solution's outputs (--no-dependencies, RestoreRecursive=false), so the eight test projects run their probes in parallel and write nothing under src/. The probe has one source file beside its project, which places the compilation in the repository for the compiler's configuration files. Test projects are unlayered: DD0001 does not check a *.Tests assembly. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- ProductCli.slnx | 10 +++ tests/Harness/Command.cs | 63 ++++++++++++++++ tests/Harness/Probe.cs | 72 +++++++++++++++++++ tests/Harness/Repository.cs | 49 +++++++++++++ .../DownwardReferenceTests.cs | 19 +++++ .../LayeringTests.cs | 25 +++++++ .../ProductCli.Canonical.Tests.csproj | 20 ++++++ tests/ProductCli.Draft.Tests/LayeringTests.cs | 25 +++++++ .../ProductCli.Draft.Tests.csproj | 20 ++++++ tests/ProductCli.Host.Tests/LayeringTests.cs | 39 ++++++++++ .../ProductCli.Host.Tests.csproj | 20 ++++++ .../ProductCli.Runner.Tests/LayeringTests.cs | 25 +++++++ .../ProductCli.Runner.Tests.csproj | 20 ++++++ .../ProductCli.Session.Tests/LayeringTests.cs | 25 +++++++ .../ProductCli.Session.Tests.csproj | 20 ++++++ .../ProductCli.Shapes.Tests/LayeringTests.cs | 25 +++++++ .../ProductCli.Shapes.Tests.csproj | 20 ++++++ .../LayeringTests.cs | 25 +++++++ .../ProductCli.Simulation.Tests.csproj | 20 ++++++ .../LayeringTests.cs | 25 +++++++ .../ProductCli.Vocabulary.Tests.csproj | 20 ++++++ tests/fixtures/Directory.Build.props | 16 +++++ tests/fixtures/layering/Probe.cs | 10 +++ .../fixtures/layering/ProductCli.Probe.csproj | 18 +++++ tests/fixtures/layering/PublicAPI.Shipped.txt | 1 + .../fixtures/layering/PublicAPI.Unshipped.txt | 0 26 files changed, 632 insertions(+) create mode 100644 tests/Harness/Command.cs create mode 100644 tests/Harness/Probe.cs create mode 100644 tests/Harness/Repository.cs create mode 100644 tests/ProductCli.Canonical.Tests/DownwardReferenceTests.cs create mode 100644 tests/ProductCli.Canonical.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj create mode 100644 tests/ProductCli.Draft.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Draft.Tests/ProductCli.Draft.Tests.csproj create mode 100644 tests/ProductCli.Host.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj create mode 100644 tests/ProductCli.Runner.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Runner.Tests/ProductCli.Runner.Tests.csproj create mode 100644 tests/ProductCli.Session.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Session.Tests/ProductCli.Session.Tests.csproj create mode 100644 tests/ProductCli.Shapes.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Shapes.Tests/ProductCli.Shapes.Tests.csproj create mode 100644 tests/ProductCli.Simulation.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Simulation.Tests/ProductCli.Simulation.Tests.csproj create mode 100644 tests/ProductCli.Vocabulary.Tests/LayeringTests.cs create mode 100644 tests/ProductCli.Vocabulary.Tests/ProductCli.Vocabulary.Tests.csproj create mode 100644 tests/fixtures/Directory.Build.props create mode 100644 tests/fixtures/layering/Probe.cs create mode 100644 tests/fixtures/layering/ProductCli.Probe.csproj create mode 100644 tests/fixtures/layering/PublicAPI.Shipped.txt create mode 100644 tests/fixtures/layering/PublicAPI.Unshipped.txt diff --git a/ProductCli.slnx b/ProductCli.slnx index 97582ac6..b9c4bcf6 100644 --- a/ProductCli.slnx +++ b/ProductCli.slnx @@ -9,4 +9,14 @@ + + + + + + + + + + diff --git a/tests/Harness/Command.cs b/tests/Harness/Command.cs new file mode 100644 index 00000000..38a4a038 --- /dev/null +++ b/tests/Harness/Command.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace ProductCli.Testing; + +/// What a process wrote and how it exited: the bytes, not an interpretation of them (ADR-0006). +internal sealed record CommandResult(int ExitCode, byte[] Stdout, byte[] Stderr) +{ + /// Stdout and stderr, decoded as UTF-8, for assertions on a compiler's messages. + public string Output => Encoding.UTF8.GetString(Stdout) + Encoding.UTF8.GetString(Stderr); +} + +/// Runs a process to completion and keeps its output bytes. +internal static class Command +{ + internal static CommandResult Run(string fileName, IEnumerable arguments, string? workingDirectory = null) + { + ProcessStartInfo start = new(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + WorkingDirectory = workingDirectory ?? Repository.Root, + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + // The test runs under `dotnet test`, which sets MSBuild's own variables + // for its child processes. A probe build is a fresh build: none of + // them may reach it. + foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables()) + { + string name = (string)variable.Key; + if (name.StartsWith("MSBUILD", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("MSBuild", StringComparison.Ordinal) + || name.Equals("DOTNET_HOST_PATH", StringComparison.OrdinalIgnoreCase)) + { + start.Environment.Remove(name); + } + } + + using Process process = Process.Start(start) + ?? throw new InvalidOperationException($"{fileName} did not start."); + process.StandardInput.Close(); + + using MemoryStream stdout = new(); + using MemoryStream stderr = new(); + Task copyOut = process.StandardOutput.BaseStream.CopyToAsync(stdout); + Task copyErr = process.StandardError.BaseStream.CopyToAsync(stderr); + process.WaitForExit(); + Task.WaitAll(copyOut, copyErr); + + return new CommandResult(process.ExitCode, stdout.ToArray(), stderr.ToArray()); + } +} diff --git a/tests/Harness/Probe.cs b/tests/Harness/Probe.cs new file mode 100644 index 00000000..0238e2ae --- /dev/null +++ b/tests/Harness/Probe.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace ProductCli.Testing; + +/// +/// Builds a probe under tests/fixtures/ with `dotnet build`, under the +/// repository's real build files (Directory.Build.*, .globalconfig, the +/// analyzers' build props), and returns what the compiler wrote. +/// +/// +/// The probe builds into its own temporary directory, against the outputs the +/// solution build already made (`--no-dependencies`, `RestoreRecursive=false`), +/// so probes run in parallel and write nothing under src/. +/// +internal static class Probe +{ + /// The layering probe, declaring (none if null) and referencing the src/ project (none if null). + internal static CommandResult Layering(int? layer, string? references) + { + Dictionary properties = []; + if (layer is int value) + { + properties["ProbeLayer"] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (references is not null) + { + string project = Repository.PathOf($"src/{references}/{references}.csproj"); + string assembly = Repository.PathOf($"src/{references}/bin/{Repository.Configuration}/net10.0/{references}.dll"); + if (!File.Exists(assembly)) + { + throw new InvalidOperationException($"{assembly} is not built. Build the solution first: the probe builds against its outputs."); + } + + properties["ProbeReference"] = project; + } + + return Build("tests/fixtures/layering/ProductCli.Probe.csproj", properties); + } + + private static CommandResult Build(string probe, Dictionary properties) + { + string artifacts = Path.Combine(Path.GetTempPath(), "productcli-probe-" + Guid.NewGuid().ToString("N")); + try + { + List arguments = + [ + "build", Repository.PathOf(probe), + "-c", Repository.Configuration, + "--no-dependencies", + "-nologo", + "-p:RestoreRecursive=false", + $"-p:ProbeArtifacts={artifacts}", + ]; + foreach (KeyValuePair property in properties) + { + arguments.Add($"-p:{property.Key}={property.Value}"); + } + + return Command.Run("dotnet", arguments); + } + finally + { + if (Directory.Exists(artifacts)) + { + Directory.Delete(artifacts, recursive: true); + } + } + } +} diff --git a/tests/Harness/Repository.cs b/tests/Harness/Repository.cs new file mode 100644 index 00000000..0e7a3e42 --- /dev/null +++ b/tests/Harness/Repository.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Reflection; + +namespace ProductCli.Testing; + +/// Where the repository is, and what the build stamped into this test assembly. +internal static class Repository +{ + /// The repository root: the nearest directory above the test's output holding global.json and ProductCli.slnx. + internal static string Root { get; } = FindRoot(); + + /// The configuration this test assembly was built in, so a probe builds against the same outputs. + internal static string Configuration => Stamped("ProductCliConfiguration"); + + /// The product version of this build (Directory.Build.props). + internal static string Version => Stamped("ProductCliVersion"); + + /// A path below the root, given with forward slashes. + internal static string PathOf(string relative) => + Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar)); + + private static string Stamped(string key) + { + foreach (AssemblyMetadataAttribute metadata in typeof(Repository).Assembly.GetCustomAttributes()) + { + if (metadata.Key == key && !string.IsNullOrEmpty(metadata.Value)) + { + return metadata.Value; + } + } + + throw new InvalidOperationException($"The build stamped no {key} into this test assembly (Directory.Build.targets)."); + } + + private static string FindRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "global.json")) + && File.Exists(Path.Combine(directory.FullName, "ProductCli.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException($"No repository root above {AppContext.BaseDirectory}."); + } +} diff --git a/tests/ProductCli.Canonical.Tests/DownwardReferenceTests.cs b/tests/ProductCli.Canonical.Tests/DownwardReferenceTests.cs new file mode 100644 index 00000000..21a47a97 --- /dev/null +++ b/tests/ProductCli.Canonical.Tests/DownwardReferenceTests.cs @@ -0,0 +1,19 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Canonical.Tests; + +/// ADR-0002 D2: a reference to a layer below compiles, so the layering tests refuse only what they should. +public sealed class DownwardReferenceTests +{ + /// A probe at layer 1 referencing ProductCli.Vocabulary, at layer 0, builds with no DD0001. + [Fact] + public void AReferenceFromLayer1ToLayer0Compiles() + { + CommandResult build = Probe.Layering(layer: 1, references: "ProductCli.Vocabulary"); + + Assert.True(build.ExitCode == 0, build.Output); + Assert.DoesNotContain("DD0001", build.Output, StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Canonical.Tests/LayeringTests.cs b/tests/ProductCli.Canonical.Tests/LayeringTests.cs new file mode 100644 index 00000000..30ea2a06 --- /dev/null +++ b/tests/ProductCli.Canonical.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Canonical.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 1 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 1 referencing ProductCli.Shapes, at layer 2, fails with DD0001. + [Fact] + public void AReferenceFromLayer1ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 1, references: "ProductCli.Shapes"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 1 and references 'ProductCli.Shapes', which is at layer 2.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj b/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj new file mode 100644 index 00000000..fb5da07b --- /dev/null +++ b/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Draft.Tests/LayeringTests.cs b/tests/ProductCli.Draft.Tests/LayeringTests.cs new file mode 100644 index 00000000..2c7f3e34 --- /dev/null +++ b/tests/ProductCli.Draft.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Draft.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 3 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 3 referencing ProductCli.Simulation, at layer 4, fails with DD0001. + [Fact] + public void AReferenceFromLayer3ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 3, references: "ProductCli.Simulation"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 3 and references 'ProductCli.Simulation', which is at layer 4.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Draft.Tests/ProductCli.Draft.Tests.csproj b/tests/ProductCli.Draft.Tests/ProductCli.Draft.Tests.csproj new file mode 100644 index 00000000..8fd973f4 --- /dev/null +++ b/tests/ProductCli.Draft.Tests/ProductCli.Draft.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Host.Tests/LayeringTests.cs b/tests/ProductCli.Host.Tests/LayeringTests.cs new file mode 100644 index 00000000..bc4ae018 --- /dev/null +++ b/tests/ProductCli.Host.Tests/LayeringTests.cs @@ -0,0 +1,39 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// +/// ADR-0002 D3 at the top of the table. Nothing sits above layer 7, so its +/// refusal is a reference within its own layer. And no ProductCli project +/// may stay out of the table (dd_require_layer). +/// +public sealed class LayeringTests +{ + /// A probe at layer 7 referencing ProductCli.Host, also at layer 7, fails with DD0001. + [Fact] + public void AReferenceFromLayer7WithinItsLayerIsRefused() + { + CommandResult build = Probe.Layering(layer: 7, references: "ProductCli.Host"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 7 and references 'ProductCli.Host', which is at layer 7.", + build.Output, + StringComparison.Ordinal); + } + + /// A ProductCli project that declares no layer fails with DD0001. + [Fact] + public void AnUnplacedProjectIsRefused() + { + CommandResult build = Probe.Layering(layer: null, references: null); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is in family 'ProductCli' and declares no layer", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj b/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj new file mode 100644 index 00000000..40b45ae8 --- /dev/null +++ b/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Runner.Tests/LayeringTests.cs b/tests/ProductCli.Runner.Tests/LayeringTests.cs new file mode 100644 index 00000000..7913ad46 --- /dev/null +++ b/tests/ProductCli.Runner.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Runner.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 5 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 5 referencing ProductCli.Session, at layer 6, fails with DD0001. + [Fact] + public void AReferenceFromLayer5ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 5, references: "ProductCli.Session"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 5 and references 'ProductCli.Session', which is at layer 6.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Runner.Tests/ProductCli.Runner.Tests.csproj b/tests/ProductCli.Runner.Tests/ProductCli.Runner.Tests.csproj new file mode 100644 index 00000000..0f30710a --- /dev/null +++ b/tests/ProductCli.Runner.Tests/ProductCli.Runner.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Session.Tests/LayeringTests.cs b/tests/ProductCli.Session.Tests/LayeringTests.cs new file mode 100644 index 00000000..e27e6270 --- /dev/null +++ b/tests/ProductCli.Session.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Session.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 6 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 6 referencing ProductCli.Host, at layer 7, fails with DD0001. + [Fact] + public void AReferenceFromLayer6ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 6, references: "ProductCli.Host"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 6 and references 'ProductCli.Host', which is at layer 7.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Session.Tests/ProductCli.Session.Tests.csproj b/tests/ProductCli.Session.Tests/ProductCli.Session.Tests.csproj new file mode 100644 index 00000000..e7e61352 --- /dev/null +++ b/tests/ProductCli.Session.Tests/ProductCli.Session.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Shapes.Tests/LayeringTests.cs b/tests/ProductCli.Shapes.Tests/LayeringTests.cs new file mode 100644 index 00000000..c814069f --- /dev/null +++ b/tests/ProductCli.Shapes.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Shapes.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 2 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 2 referencing ProductCli.Draft, at layer 3, fails with DD0001. + [Fact] + public void AReferenceFromLayer2ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 2, references: "ProductCli.Draft"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 2 and references 'ProductCli.Draft', which is at layer 3.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Shapes.Tests/ProductCli.Shapes.Tests.csproj b/tests/ProductCli.Shapes.Tests/ProductCli.Shapes.Tests.csproj new file mode 100644 index 00000000..cf21e9c7 --- /dev/null +++ b/tests/ProductCli.Shapes.Tests/ProductCli.Shapes.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Simulation.Tests/LayeringTests.cs b/tests/ProductCli.Simulation.Tests/LayeringTests.cs new file mode 100644 index 00000000..e1c4769b --- /dev/null +++ b/tests/ProductCli.Simulation.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Simulation.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 4 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 4 referencing ProductCli.Runner, at layer 5, fails with DD0001. + [Fact] + public void AReferenceFromLayer4ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 4, references: "ProductCli.Runner"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 4 and references 'ProductCli.Runner', which is at layer 5.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Simulation.Tests/ProductCli.Simulation.Tests.csproj b/tests/ProductCli.Simulation.Tests/ProductCli.Simulation.Tests.csproj new file mode 100644 index 00000000..8e1563ac --- /dev/null +++ b/tests/ProductCli.Simulation.Tests/ProductCli.Simulation.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/ProductCli.Vocabulary.Tests/LayeringTests.cs b/tests/ProductCli.Vocabulary.Tests/LayeringTests.cs new file mode 100644 index 00000000..9c4af832 --- /dev/null +++ b/tests/ProductCli.Vocabulary.Tests/LayeringTests.cs @@ -0,0 +1,25 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Vocabulary.Tests; + +/// +/// ADR-0002 D3: the layering analyzer refuses a reference from layer 0 to the +/// layer above it. The test reads the refusal in the compiler's output. +/// +public sealed class LayeringTests +{ + /// A probe at layer 0 referencing ProductCli.Canonical, at layer 1, fails with DD0001. + [Fact] + public void AReferenceFromLayer0ToTheLayerAboveIsRefused() + { + CommandResult build = Probe.Layering(layer: 0, references: "ProductCli.Canonical"); + + Assert.NotEqual(0, build.ExitCode); + Assert.Contains( + "error DD0001: 'ProductCli.Probe' is at layer 0 and references 'ProductCli.Canonical', which is at layer 1.", + build.Output, + StringComparison.Ordinal); + } +} diff --git a/tests/ProductCli.Vocabulary.Tests/ProductCli.Vocabulary.Tests.csproj b/tests/ProductCli.Vocabulary.Tests/ProductCli.Vocabulary.Tests.csproj new file mode 100644 index 00000000..1a0a5471 --- /dev/null +++ b/tests/ProductCli.Vocabulary.Tests/ProductCli.Vocabulary.Tests.csproj @@ -0,0 +1,20 @@ + + + + + Exe + + + + + + + + + + + + + + + diff --git a/tests/fixtures/Directory.Build.props b/tests/fixtures/Directory.Build.props new file mode 100644 index 00000000..f9b0e365 --- /dev/null +++ b/tests/fixtures/Directory.Build.props @@ -0,0 +1,16 @@ + + + + + $(ProbeArtifacts)/obj/ + $(ProbeArtifacts)/bin/ + + + + + diff --git a/tests/fixtures/layering/Probe.cs b/tests/fixtures/layering/Probe.cs new file mode 100644 index 00000000..1c6730c6 --- /dev/null +++ b/tests/fixtures/layering/Probe.cs @@ -0,0 +1,10 @@ +namespace ProductCli.Probe; + +/// +/// The probe's one source file. It places the compilation in this directory, +/// so the compiler reads the repository's .editorconfig and .globalconfig as +/// it does for a library under src/. +/// +internal static class Probe +{ +} diff --git a/tests/fixtures/layering/ProductCli.Probe.csproj b/tests/fixtures/layering/ProductCli.Probe.csproj new file mode 100644 index 00000000..e4648221 --- /dev/null +++ b/tests/fixtures/layering/ProductCli.Probe.csproj @@ -0,0 +1,18 @@ + + + + + $(ProbeLayer) + + + + + + + diff --git a/tests/fixtures/layering/PublicAPI.Shipped.txt b/tests/fixtures/layering/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/tests/fixtures/layering/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/tests/fixtures/layering/PublicAPI.Unshipped.txt b/tests/fixtures/layering/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b From af27ad42f0ef624fca60867b324bdf3eeef45e24 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:41:00 +0000 Subject: [PATCH 07/14] test(canonical): the Varve admission test, with the vendored example ADR-0004 D2's evidence for the Varve preview this repository references, 0.1.0-preview.15 (tag 6cdb402). The earlier canonicaliser checks ran on Varve main at c0a776b, 120 commits later; this is the first check of the bytes that ship. decision-driven-design's applications/sdlc/what/examples/acme-shop.ttl and applications/sdlc/what/vectors.json are vendored under tests/fixtures/ddd/ at 799cbe84f2a010fb5b65a687eb253666948c73c8, with SOURCE.md giving the commit, the paths and each file's sha256: - acme-shop.ttl 4018cb9ad45c13fceafd7e585649777e7c8b9e7231fab46a42eb3286df91b777 - vectors.json 953666f42051e50632290f73200298c9b5d518d2a153c20a31f9589f94374a1d The test checks both hashes against SOURCE.md, parses the Turtle with Varve.Turtle, canonicalises it with Varve.Rdf's RdfCanonicaliser, and asserts that the sha256 of the canonical N-Quads bytes is vectors.json's graphHash, sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b. It passes. Varve.Turtle is test-only (Scope tests/). Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .../ProductCli.Canonical.Tests.csproj | 1 + .../VarveAdmissionTests.cs | 57 ++++++ tests/fixtures/ddd/SOURCE.md | 18 ++ tests/fixtures/ddd/acme-shop.ttl | 124 +++++++++++ tests/fixtures/ddd/vectors.json | 192 ++++++++++++++++++ 5 files changed, 392 insertions(+) create mode 100644 tests/ProductCli.Canonical.Tests/VarveAdmissionTests.cs create mode 100644 tests/fixtures/ddd/SOURCE.md create mode 100644 tests/fixtures/ddd/acme-shop.ttl create mode 100644 tests/fixtures/ddd/vectors.json diff --git a/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj b/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj index fb5da07b..fa5a7459 100644 --- a/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj +++ b/tests/ProductCli.Canonical.Tests/ProductCli.Canonical.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/ProductCli.Canonical.Tests/VarveAdmissionTests.cs b/tests/ProductCli.Canonical.Tests/VarveAdmissionTests.cs new file mode 100644 index 00000000..28a27ad4 --- /dev/null +++ b/tests/ProductCli.Canonical.Tests/VarveAdmissionTests.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using ProductCli.Testing; +using Varve.Rdf; +using Varve.Turtle; +using Xunit; + +namespace ProductCli.Canonical.Tests; + +/// +/// The admission evidence for the Varve preview this repository references +/// (ADR-0004 D2): its Turtle parser and its RDFC-1.0 canonicaliser reproduce +/// decision-driven-design's published graph hash of acme-shop, byte for byte. +/// A test of the package, not of product behaviour; a failure is a Varve +/// issue first (ADR-0002 D5). +/// +public sealed class VarveAdmissionTests +{ + private static string Fixture(string name) => Path.Combine(Repository.PathOf("tests/fixtures/ddd"), name); + + /// The vendored files are the bytes SOURCE.md records. + [Fact] + public void TheVendoredFilesAreTheRecordedBytes() + { + string source = File.ReadAllText(Fixture("SOURCE.md")); + + Assert.Contains($"`{Sha256(File.ReadAllBytes(Fixture("acme-shop.ttl")))}`", source, StringComparison.Ordinal); + Assert.Contains($"`{Sha256(File.ReadAllBytes(Fixture("vectors.json")))}`", source, StringComparison.Ordinal); + } + + /// The sha256 of acme-shop's canonical N-Quads is vectors.json's graphHash. + [Fact] + public void TheCanonicalNQuadsOfAcmeShopHashToThePublishedGraphHash() + { + InMemoryDatasetBuilder builder = new(); + ParseResult parsed = TurtleParser.Parse( + File.ReadAllBytes(Fixture("acme-shop.ttl")), + (in QuadView quad) => builder.Add( + quad.Subject.Materialise(), + quad.Predicate.Materialise(), + quad.Object.Materialise(), + quad.HasGraph ? quad.Graph.Materialise() : null), + new TurtleOptions { Syntax = RdfSyntax.Turtle }); + Assert.True(parsed.Succeeded, $"acme-shop.ttl does not parse: {parsed.FirstError}"); + + CanonicalDataset canonical = RdfCanonicaliser.Canonicalise(builder.ToDataset(), cancellationToken: TestContext.Current.CancellationToken); + + using JsonDocument vectors = JsonDocument.Parse(File.ReadAllBytes(Fixture("vectors.json"))); + string? published = vectors.RootElement.GetProperty("graphHash").GetString(); + Assert.Equal("sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b", published); + Assert.Equal(published, "sha256:" + Sha256(canonical.NQuads.Span)); + } + + private static string Sha256(ReadOnlySpan bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); +} diff --git a/tests/fixtures/ddd/SOURCE.md b/tests/fixtures/ddd/SOURCE.md new file mode 100644 index 00000000..e692f21f --- /dev/null +++ b/tests/fixtures/ddd/SOURCE.md @@ -0,0 +1,18 @@ +# Vendored from decision-driven-design + +These files are copied byte for byte, for the Varve admission test +(`tests/ProductCli.Canonical.Tests/VarveAdmissionTests.cs`, ADR-0004 D2, #167). +They are never edited here. `.gitattributes` marks them `-text`, so no checkout +changes their bytes. The test checks each one's sha256 against this file. + +- **Repository:** `mindovermachine-dev/decision-driven-design` +- **Commit:** `799cbe84f2a010fb5b65a687eb253666948c73c8` (its `main` on + 2026-11-03, which carries D1.25). Both files were last changed in `0c94c32`. + +| File here | Path there | sha256 | +|---|---|---| +| `acme-shop.ttl` | `applications/sdlc/what/examples/acme-shop.ttl` | `4018cb9ad45c13fceafd7e585649777e7c8b9e7231fab46a42eb3286df91b777` | +| `vectors.json` | `applications/sdlc/what/vectors.json` | `953666f42051e50632290f73200298c9b5d518d2a153c20a31f9589f94374a1d` | + +`vectors.json`'s `graphHash`, the RDFC-1.0 hash of `acme-shop.ttl`, is +`sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b`. diff --git a/tests/fixtures/ddd/acme-shop.ttl b/tests/fixtures/ddd/acme-shop.ttl new file mode 100644 index 00000000..666aa7f4 --- /dev/null +++ b/tests/fixtures/ddd/acme-shop.ttl @@ -0,0 +1,124 @@ +# The acme-shop What — the worked example of applications/sdlc/what.md. +# +# The How test product's What (applications/sdlc/how-examples/), landed in the ruled identity +# form (DDD-dec-63): the product node is the product IRI; every other element is +# P/what/, minted once by what/mint.py on 2026-10-28 and never changed. Names are +# rdfs:label. A test product only: nothing here is acceptable into a real product (ruling 4 of +# 2026-10-28). Blank nodes appear only inside an element's description (scenario lists, scope +# keys, payload bindings). +@prefix sdlc: . +@prefix rdfs: . +@prefix xsd: . +@prefix w: . + + a sdlc:Product ; + rdfs:label "Acme Shop" ; + sdlc:purpose "Sell to consumers and refund them, over one ordering model." . + +# ---- §3.1 the Ordering domain +w:01M610JNAMYTKTBXP8KD0BQ2G0 a sdlc:Domain ; sdlc:within ; rdfs:label "Ordering" . +w:01M610JNAMH5Y3FP9BFRZJ6QSS a sdlc:BoundedContext ; sdlc:within w:01M610JNAMYTKTBXP8KD0BQ2G0 ; rdfs:label "Ordering" ; + sdlc:ubiquitousTerm "Order" , "Refund" . + +w:01M610JNAMCAGMKBDKSY0GQ0S1 a sdlc:Entity ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order" ; + sdlc:definition "A placed, paid order." ; sdlc:isAggregateRoot true . +w:01M610JNAM71CMH9AJ4YSGX3MV a sdlc:Field ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "customer_id" ; sdlc:fieldType xsd:string . +w:01M610JNAMBV0GKE9KYASYPJCA a sdlc:Field ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "paid_total" ; sdlc:fieldType xsd:integer . +w:01M610JNAMHGKDY5GHJRB7FGRM a sdlc:Field ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "refunded_total" ; sdlc:fieldType xsd:integer . + +w:01M610JNAMBF0DD4V6Y2W1XFC7 a sdlc:Invariant ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "order has lines" ; + sdlc:wireName "order-has-lines" ; sdlc:statement "An order cannot be placed with no lines." . +w:01M610JNAMRDJE9PM59PYRRD3J a sdlc:Invariant ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "refund by owner" ; + sdlc:wireName "refund-by-owner" ; sdlc:statement "Only the order's customer may request its refund." . +w:01M610JNAMBBMM4NHG16BQTNRE a sdlc:Invariant ; sdlc:within w:01M610JNAMCAGMKBDKSY0GQ0S1 ; rdfs:label "refund within paid" ; + sdlc:wireName "refund-within-paid" ; sdlc:statement "refunded_total + amount <= paid_total." . + +# ---- §3.2 events and commands, with payload fields in order +w:01M610JNAMCQ2R6MG31Q07DWMX a sdlc:Command ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Place order" ; sdlc:wireName "place-order" ; + sdlc:targets w:01M610JNAMCAGMKBDKSY0GQ0S1 ; sdlc:emits w:01M610JNAM3FKSYQJ0NPEMRJQ5 ; sdlc:fieldOrder ( w:01M610JNAMPJPTACP44JCE5B2S ) . +w:01M610JNAMPJPTACP44JCE5B2S a sdlc:Field ; sdlc:within w:01M610JNAMCQ2R6MG31Q07DWMX ; rdfs:label "lines" ; sdlc:wireName "lines" ; sdlc:fieldType xsd:integer . +w:01M610JNAMC74SDA3DVDE332WB a sdlc:Command ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Refund order" ; sdlc:wireName "refund-order" ; + sdlc:targets w:01M610JNAMCAGMKBDKSY0GQ0S1 ; sdlc:emits w:01M610JNAMJPDE0DG2T4TY6X9A ; sdlc:fieldOrder ( w:01M610JNAMXAPW6HP60ZS6H37V ) . +w:01M610JNAMXAPW6HP60ZS6H37V a sdlc:Field ; sdlc:within w:01M610JNAMC74SDA3DVDE332WB ; rdfs:label "amount" ; sdlc:wireName "amount" ; sdlc:fieldType xsd:integer . + +w:01M610JNAM3FKSYQJ0NPEMRJQ5 a sdlc:Event ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order placed" ; sdlc:wireName "order-placed" ; + sdlc:changes w:01M610JNAMCAGMKBDKSY0GQ0S1 ; sdlc:fieldOrder ( w:01M610JNAM0RKJ7T4099SW1N77 w:01M610JNAM959BR6Y50HZ3WME4 ) . +w:01M610JNAM0RKJ7T4099SW1N77 a sdlc:Field ; sdlc:within w:01M610JNAM3FKSYQJ0NPEMRJQ5 ; rdfs:label "customer_id" ; sdlc:wireName "customer_id" ; sdlc:fieldType xsd:string . +w:01M610JNAM959BR6Y50HZ3WME4 a sdlc:Field ; sdlc:within w:01M610JNAM3FKSYQJ0NPEMRJQ5 ; rdfs:label "paid_total" ; sdlc:wireName "paid_total" ; sdlc:fieldType xsd:integer . +w:01M610JNAMJPDE0DG2T4TY6X9A a sdlc:Event ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order refunded" ; sdlc:wireName "order-refunded" ; + sdlc:changes w:01M610JNAMCAGMKBDKSY0GQ0S1 ; sdlc:fieldOrder ( w:01M610JNAM2HPRB9QBTYD15XZ7 ) . +w:01M610JNAM2HPRB9QBTYD15XZ7 a sdlc:Field ; sdlc:within w:01M610JNAMJPDE0DG2T4TY6X9A ; rdfs:label "amount" ; sdlc:wireName "amount" ; sdlc:fieldType xsd:integer . + +# ---- §3.3 the Decider, its logic by digest, and one scenario +w:01M610JNAMMP5V59XRNHD39R5A a sdlc:Decider ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order" ; + sdlc:decidesFor w:01M610JNAMCAGMKBDKSY0GQ0S1 ; sdlc:handles w:01M610JNAMCQ2R6MG31Q07DWMX , w:01M610JNAMC74SDA3DVDE332WB ; + sdlc:reads w:01M610JNAM71CMH9AJ4YSGX3MV , w:01M610JNAMBV0GKE9KYASYPJCA , w:01M610JNAMHGKDY5GHJRB7FGRM , w:01M610JNAMQHZ7H4Y0GVEQAWBG ; + sdlc:rejects w:01M610JNAMBF0DD4V6Y2W1XFC7 , w:01M610JNAMRDJE9PM59PYRRD3J , w:01M610JNAMBBMM4NHG16BQTNRE ; + sdlc:logic "sha256:24c18e0148752eab9667545a85182b9ee7ab03d8d7c60d8259f5b69e6a260e6b" ; sdlc:logicMediaType "application/yaml" . +w:01M610JNAM51J0KCR35NQCD9YC a sdlc:Scenario ; sdlc:within w:01M610JNAMMP5V59XRNHD39R5A ; rdfs:label "refund beyond what was paid" ; + sdlc:given ( [ sdlc:event w:01M610JNAM3FKSYQJ0NPEMRJQ5 ; + sdlc:with [ sdlc:field w:01M610JNAM0RKJ7T4099SW1N77 ; sdlc:value "c-1" ] , [ sdlc:field w:01M610JNAM959BR6Y50HZ3WME4 ; sdlc:value "100"^^xsd:integer ] ] ) ; + sdlc:when [ sdlc:command w:01M610JNAMC74SDA3DVDE332WB ; sdlc:asSubject w:01M610JNAMMSAY8ZWD1TMMZ8HH ; + sdlc:with [ sdlc:field w:01M610JNAMXAPW6HP60ZS6H37V ; sdlc:value "150"^^xsd:integer ] , [ sdlc:field w:01M610JNAMQHZ7H4Y0GVEQAWBG ; sdlc:value "c-1" ] ] ; + sdlc:thenReject w:01M610JNAMBBMM4NHG16BQTNRE . + +# ---- §3.2, §3.4 the View and its Projector, keyed for scoping +w:01M610JNAMFTV81M9FR1TXEN03 a sdlc:View ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order summary" ; + sdlc:projects w:01M610JNAM3FKSYQJ0NPEMRJQ5 , w:01M610JNAMJPDE0DG2T4TY6X9A ; sdlc:hasState sdlc:statePresent , sdlc:stateEmpty . +w:01M610JNAM10QDGKF29GWXSHK1 a sdlc:Field ; sdlc:within w:01M610JNAMFTV81M9FR1TXEN03 ; rdfs:label "customer_id" ; sdlc:wireName "customer_id" ; sdlc:fieldType xsd:string . +w:01M610JNAMD2PDDKQRCW18Z0E4 a sdlc:Projector ; sdlc:within w:01M610JNAMH5Y3FP9BFRZJ6QSS ; rdfs:label "Order summary" ; + sdlc:projectsFor w:01M610JNAMFTV81M9FR1TXEN03 ; sdlc:folds w:01M610JNAM3FKSYQJ0NPEMRJQ5 , w:01M610JNAMJPDE0DG2T4TY6X9A ; sdlc:keyedBy w:01M610JNAM10QDGKF29GWXSHK1 ; + sdlc:logic "sha256:a39a05f9c3f992f4f839c892bab65c7d45fc23a1f5e52a1ca9a793045f93770d" ; sdlc:logicMediaType "application/yaml" . + +# ---- §3.7 subject kinds, their claims, and the View's scope +w:01M610JNAMMSAY8ZWD1TMMZ8HH a sdlc:SubjectKind ; sdlc:within w:01M610JNAMYTKTBXP8KD0BQ2G0 ; rdfs:label "Customer" ; sdlc:wireName "customer" ; sdlc:nature sdlc:natureHuman . +w:01M610JNAMQHZ7H4Y0GVEQAWBG a sdlc:Field ; sdlc:within w:01M610JNAMMSAY8ZWD1TMMZ8HH ; rdfs:label "customer_id" ; sdlc:wireName "customer_id" ; sdlc:fieldType xsd:string . +w:01M610JNAMHKDH8MYYBGG3DBMA a sdlc:SubjectKind ; sdlc:within w:01M610JNAMYTKTBXP8KD0BQ2G0 ; rdfs:label "Staff" ; sdlc:wireName "staff" ; sdlc:nature sdlc:natureHuman . +w:01M610JNAMF373V3PH2FNTY837 a sdlc:ScopeRule ; sdlc:within w:01M610JNAMFTV81M9FR1TXEN03 ; rdfs:label "customers see their own orders" ; + sdlc:forSubject w:01M610JNAMMSAY8ZWD1TMMZ8HH ; + sdlc:key [ sdlc:viewField w:01M610JNAM10QDGKF29GWXSHK1 ; sdlc:claim w:01M610JNAMQHZ7H4Y0GVEQAWBG ; sdlc:operator "equals" ] . +w:01M610JNAM10YNEPYPSXB6RMCT a sdlc:ScopeRule ; sdlc:within w:01M610JNAMFTV81M9FR1TXEN03 ; rdfs:label "staff see every order" ; + sdlc:forSubject w:01M610JNAMHKDH8MYYBGG3DBMA . + +# ---- §3.2.5 the systems +w:01M610JNAMV23W7Y9YQKZPJ4ZA a sdlc:System ; sdlc:within ; rdfs:label "shop-api" ; + sdlc:systemKind "service" ; sdlc:purpose "The ordering service other systems call." ; + sdlc:referencesDomain w:01M610JNAMYTKTBXP8KD0BQ2G0 . +w:01M610JNAM8K9PG653537FE7EG a sdlc:System ; sdlc:within ; rdfs:label "shop-bff" ; + sdlc:systemKind "application" ; sdlc:purpose "The customer-facing web shop." ; + sdlc:referencesDomain w:01M610JNAMYTKTBXP8KD0BQ2G0 ; sdlc:targetsClass sdlc:GUI ; sdlc:targetsContext w:01M610JNAMPMZF1RVXMWH5WTHK . +w:01M610JNAMPMZF1RVXMWH5WTHK a sdlc:ContextOfUse ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "web" ; sdlc:dimension "platform" . +w:01M610JNAMM87QX4FY174WWP2R a sdlc:DeclaredTarget ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "Acme design system" ; + sdlc:targetKind "design-system" ; sdlc:edition "DS@1@https://design.acme.example/packages/acme-ds" . +w:01M610JNAM4J9NN1M290V2F9WH a sdlc:DeclaredTarget ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "Acme content store" ; + sdlc:targetKind "content-store" ; sdlc:edition "CS@1@https://design.acme.example/packages/acme-content" ; + sdlc:locale "en-GB" , "da-DK" . + +# ---- §3.2.0, §3.2.1, §3.2.4 triggers, a flow, a UI step, the root +w:01M610JNAMGPYXD4X3VAE9RBC0 a sdlc:Trigger ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "customer places an order" ; + sdlc:source sdlc:sourceUser ; sdlc:subject w:01M610JNAMMSAY8ZWD1TMMZ8HH ; sdlc:issues w:01M610JNAMCQ2R6MG31Q07DWMX . +w:01M610JNAMHZ5N0XY7Z0YCH227 a sdlc:Trigger ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "customer asks for a refund" ; + sdlc:source sdlc:sourceUser ; sdlc:subject w:01M610JNAMMSAY8ZWD1TMMZ8HH ; sdlc:issues w:01M610JNAMC74SDA3DVDE332WB . + +w:01M610JNAM18X2V4SPKEV6G1M5 a sdlc:SystemRoot ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "shop-bff root" ; sdlc:navigatesFromRoot w:01M610JNAMWPGKQNH3B0N3KBTH . +w:01M610JNAMWPGKQNH3B0N3KBTH a sdlc:Flow ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "refund" ; sdlc:entryPage w:01M610JNAM76HYQ9AXTP6ZDY81 . +w:01M610JNAM76HYQ9AXTP6ZDY81 a sdlc:UIStep ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "Order detail" ; sdlc:inFlow w:01M610JNAMWPGKQNH3B0N3KBTH ; + sdlc:intent "The customer checks an order and asks for part of it back." ; + sdlc:emphasis "What is still refundable." ; + sdlc:offeredTo w:01M610JNAMMSAY8ZWD1TMMZ8HH ; + sdlc:references w:01M610JNAM8Y7FS4CTRQQV5KR5 ; + sdlc:mustSatisfy . +w:01M610JNAM967F0A5307C936ZR a sdlc:Interaction ; sdlc:within w:01M610JNAM76HYQ9AXTP6ZDY81 ; rdfs:label "show the order" ; + sdlc:projection w:01M610JNAMFTV81M9FR1TXEN03 ; sdlc:typedAs sdlc:aioDisplayValue . +w:01M610JNAM9S56WC3FTWZWGS35 a sdlc:Interaction ; sdlc:within w:01M610JNAM76HYQ9AXTP6ZDY81 ; rdfs:label "enter the amount" ; + sdlc:field w:01M610JNAMXAPW6HP60ZS6H37V ; sdlc:typedAs sdlc:aioNumericEntry . +w:01M610JNAMEGJXF540DC0FMQXC a sdlc:Interaction ; sdlc:within w:01M610JNAM76HYQ9AXTP6ZDY81 ; rdfs:label "ask for the refund" ; + sdlc:command w:01M610JNAMC74SDA3DVDE332WB ; sdlc:typedAs sdlc:aioTriggerAction . +w:01M610JNAMJD5JN6VT0J0KZQW0 a sdlc:StateAnnotation ; sdlc:within w:01M610JNAM76HYQ9AXTP6ZDY81 ; rdfs:label "no such order" ; + sdlc:forState sdlc:stateEmpty ; sdlc:meaning "The order is not the customer's or does not exist; offer the way back to their orders." . +w:01M610JNAM8Y7FS4CTRQQV5KR5 a sdlc:ContentKey ; sdlc:within w:01M610JNAM8K9PG653537FE7EG ; rdfs:label "order detail heading" ; sdlc:role "heading" . + +# ---- §3.6 a product-wide architectural constraint +w:01M610JNAMGQQ2DJENWGA6MWCK a sdlc:ArchitecturalConstraint ; sdlc:within ; + rdfs:label "personal data resides in the EU" ; + sdlc:statement "Every store of personal data, in every deployment of every system, is in an EU-jurisdiction region." . diff --git a/tests/fixtures/ddd/vectors.json b/tests/fixtures/ddd/vectors.json new file mode 100644 index 00000000..e80506bc --- /dev/null +++ b/tests/fixtures/ddd/vectors.json @@ -0,0 +1,192 @@ +{ + "canon": "rdfc-1.0", + "hash": "sha256", + "example": "examples/acme-shop.ttl", + "graphHash": "sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b", + "synthetic": { + "nTriples": " _:l1 .\n_:l1 _:e1 .\n_:l1 .\n_:e1 \"100\"^^ .\n", + "hash": "sha256:456983e04c5fbe95df100d4fe330cb44580bc308f0a2c1ee414e580e6f7a32b7" + }, + "elements": [ + { + "iri": "https://acme.example/products/shop", + "hash": "sha256:2b1fdeb864fe39e494867c78a6227bccf93806f99ec65ce34721cf3cb6ca801a" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM0RKJ7T4099SW1N77", + "hash": "sha256:72c87f19698b2a2b3103387446144aae421202dc183f3594697a040669c6cae4" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM10QDGKF29GWXSHK1", + "hash": "sha256:73a035341e751ed2bb8baf42086a23a3aff143dcb6a8e8ab50f6de3a75d706da" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM10YNEPYPSXB6RMCT", + "hash": "sha256:ce5ba6ea8c44df4a1da04bf9b0e480f551d9472cb57174859ea69520dafeb1c6" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM18X2V4SPKEV6G1M5", + "hash": "sha256:c7b1df959361273c1efff04d77cee49748dc0ee294510c0f5492c31ab96ba9ba" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM2HPRB9QBTYD15XZ7", + "hash": "sha256:68bc7bcf89accbbf5d397bbe4539a78eaa687cf00ae61a7dea393cdf93cb247b" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM3FKSYQJ0NPEMRJQ5", + "hash": "sha256:f580f283b177fbbf9ee09b4d6216be4f0aeb7b7926c5a8e1963961c958d449ac" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM4J9NN1M290V2F9WH", + "hash": "sha256:8ddcd598734a2836d2aba2c9d8cd590f4c69367cf0a17d33a0ccbdd4ba95f286" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM51J0KCR35NQCD9YC", + "hash": "sha256:1ec36c54959643ae45a5c1ed74a633582ed09d775572d61917c3ad4841b9e38b" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM71CMH9AJ4YSGX3MV", + "hash": "sha256:b8c8d3a1ae709a8073b97c70159bf1b07ef59209419ffaa70f31bb1a8af17df5" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM76HYQ9AXTP6ZDY81", + "hash": "sha256:d1964da52198c7e58de9cd9583af19de9bde843710c43693ab6e9e876b70e1e0" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM8K9PG653537FE7EG", + "hash": "sha256:a0fd4bd4b34119fc14df665d4a313b245d9ef3c5d84698aecd8fe8e9e4cab6dc" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM8Y7FS4CTRQQV5KR5", + "hash": "sha256:dacbef67f039a6e756adca389f862d4bbd602318125305dcbd742dbae461ce47" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM959BR6Y50HZ3WME4", + "hash": "sha256:2d09be2ea9b1772167c21d1688fa9cc0a853932e0d8dc09ac6bdee110ed98538" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM967F0A5307C936ZR", + "hash": "sha256:122c07c9a064755f5577dc07357c10a7f4dbdf730422cba9de133e3105f2495f" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAM9S56WC3FTWZWGS35", + "hash": "sha256:358128c01b3ef343e2835440f9681ec596edaa869969cc7e6880e5b733806530" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMBBMM4NHG16BQTNRE", + "hash": "sha256:e9699fa7cdaec72cc90b0ecc011f7e5548424fd82efb3a744c35ad63a20210df" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMBF0DD4V6Y2W1XFC7", + "hash": "sha256:f918db3aeec1bafa5c474d4f7e983c89452261887b84ddf3e5cba5994f5d6248" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMBV0GKE9KYASYPJCA", + "hash": "sha256:0982b5a8549d51a9fa73072dd95242ef31574d795c36a789c1cef6e90db21d65" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMC74SDA3DVDE332WB", + "hash": "sha256:d95e8e5672e128ce12754bc5caeac974c19ddc9a6d0cc339a8a5964c52530883" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMCAGMKBDKSY0GQ0S1", + "hash": "sha256:ea78cb132a964543c8fa416c6ccf608c02e5789bdfe0525526d7a196f1b6c8d3" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMCQ2R6MG31Q07DWMX", + "hash": "sha256:2698921179975ced5d453809017bdc4df7cfe0ceae4b5c9a2eab04e6a5d7a7e4" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMD2PDDKQRCW18Z0E4", + "hash": "sha256:57c43ce99d87d2c3ea3cdb718408cfe5a819d23d393197e083c5082c863638d4" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMEGJXF540DC0FMQXC", + "hash": "sha256:b5bc478d40165d620c055e4cd3b4374b9395ba43e6b8e928366b55e8a9c539a9" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMF373V3PH2FNTY837", + "hash": "sha256:034943505fd3432d08a18666febb9139de521368940fa5d0d8cb168536e43df1" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMFTV81M9FR1TXEN03", + "hash": "sha256:0260544ff84f19c4792ca6655ea5fcf354bffd24fedd83a99660d201963e6672" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMGPYXD4X3VAE9RBC0", + "hash": "sha256:de8f2e6465fad36c039a11481acdf086cc479f7e33d08186b56040ee02075deb" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMGQQ2DJENWGA6MWCK", + "hash": "sha256:f08812d331db9438f22bac784a574a144f834c7f6848184f0676be201873d76a" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMH5Y3FP9BFRZJ6QSS", + "hash": "sha256:e12084a29798c0d791774b33d564aa6415e161540eb36a6951e23ee32f84abc4" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMHGKDY5GHJRB7FGRM", + "hash": "sha256:69b19ceafacf094a024cbe44d474789d7656772b805c849c9744badb61867b96" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMHKDH8MYYBGG3DBMA", + "hash": "sha256:a1736d09f1f5f8eac3a2f4cbf70a7ef89c094a286c4d54e2808b37e97fba5cd4" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMHZ5N0XY7Z0YCH227", + "hash": "sha256:45a388b9644baa86747cdc5736a5ca67ae369618259a36c491be9f90c82c825d" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMJD5JN6VT0J0KZQW0", + "hash": "sha256:6e7c0e57645eecc4e726b9ad0c2ae0f0525a7db11b24521548a9ac0f1d2acd97" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMJPDE0DG2T4TY6X9A", + "hash": "sha256:877eea6beb5557dd8f8c8455528dd8263381a1e8f68e722ea6822c324b27ab7f" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMM87QX4FY174WWP2R", + "hash": "sha256:a70da49495a985695dbe520369e092b7758a857a85618910db49c13b23e0b63f" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMMP5V59XRNHD39R5A", + "hash": "sha256:e7c5a0997a329eefb5241a729cf9c772f20d1809a204bd019daebf727670ac58" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMMSAY8ZWD1TMMZ8HH", + "hash": "sha256:f1771b80380ac51d0b6d4dead9249d6a66bd5b598441f1d256d4b849db85aff6" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMPJPTACP44JCE5B2S", + "hash": "sha256:3bde8df54e6abcff04036c81ae1381314fd5632ac42d043280062e9b2f62da16" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMPMZF1RVXMWH5WTHK", + "hash": "sha256:bcefd002d99a0cb6206ed7257ca6598d2483e61d1c3bc40826e3da01342d82e5" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMQHZ7H4Y0GVEQAWBG", + "hash": "sha256:44dbe676eab651b267e32b17bb15c115ee445c1b7b8777ffe9aba773cf4d93e2" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMRDJE9PM59PYRRD3J", + "hash": "sha256:d4b648f82fa281f0717d3ed1e3c8874f32564b97bac5d759516ef1dcd44eb143" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMV23W7Y9YQKZPJ4ZA", + "hash": "sha256:e5481e46700d51bbb6dd7ae417f94a02fa02bb31b600c14d409d528c15fde3e2" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMWPGKQNH3B0N3KBTH", + "hash": "sha256:93ecfa4f32a1190fe88899305bb1ebd59d0077b8f27b52ff76bf38e004c62dfc" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMXAPW6HP60ZS6H37V", + "hash": "sha256:e90a552b7259f80968ee6e67653667161ae1a74c124dcb40f9795bde96ee359d" + }, + { + "iri": "https://acme.example/products/shop/what/01M610JNAMYTKTBXP8KD0BQ2G0", + "hash": "sha256:3803a2cd3f5997d78955df34358001c6426d32698d98d15e910fd1b7eee232d7" + } + ] +} From b0048205d0d88bd1a520aea66521ba7ecf059e71 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:41:00 +0000 Subject: [PATCH 08/14] test(host): the version bytes, the verb messages, the argument property Each test runs the built host as a process and reads its stdout and stderr bytes (ADR-0006). - --version: stdout is exactly "product (commit )\n", where the version is the build's and the commit is git rev-parse HEAD; stderr is empty; the exit code is 0. - Each chapter-3 verb: exit 2, nothing on stdout, and exactly its line on stderr. No argument, an argument that is not a verb, and --version with an argument each have theirs. - The property (CsCheck, 100 cases): any argument vector but exactly ["--version"], the empty one included, exits 2 with nothing on stdout and exactly one line on stderr beginning "product: ". - The layer table: each built assembly's [assembly: ArchLayer(n)], read from its metadata where DD0001 reads it, is its row of ADR-0002 D1. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .../ArgumentPropertyTests.cs | 41 ++++++++++++ tests/ProductCli.Host.Tests/HostProcess.cs | 19 ++++++ .../ProductCli.Host.Tests/LayerTableTests.cs | 65 +++++++++++++++++++ .../ProductCli.Host.Tests.csproj | 1 + tests/ProductCli.Host.Tests/VerbTests.cs | 63 ++++++++++++++++++ tests/ProductCli.Host.Tests/VersionTests.cs | 28 ++++++++ 6 files changed, 217 insertions(+) create mode 100644 tests/ProductCli.Host.Tests/ArgumentPropertyTests.cs create mode 100644 tests/ProductCli.Host.Tests/HostProcess.cs create mode 100644 tests/ProductCli.Host.Tests/LayerTableTests.cs create mode 100644 tests/ProductCli.Host.Tests/VerbTests.cs create mode 100644 tests/ProductCli.Host.Tests/VersionTests.cs diff --git a/tests/ProductCli.Host.Tests/ArgumentPropertyTests.cs b/tests/ProductCli.Host.Tests/ArgumentPropertyTests.cs new file mode 100644 index 00000000..3914902f --- /dev/null +++ b/tests/ProductCli.Host.Tests/ArgumentPropertyTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using CsCheck; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// The host's refusal, as a property over argument vectors (CsCheck, ADR-0013 D1). +public sealed class ArgumentPropertyTests +{ + /// + /// For any argument vector other than exactly ["--version"], the + /// empty one included: exit 2, nothing on stdout, and exactly one line on + /// stderr, beginning product: . + /// + [Fact] + public void EveryArgumentVectorButVersionExitsTwoWithOneLine() + { + // Any character but NUL, which no operating system passes in an + // argument, and the surrogates, which are not characters alone. + Gen anyText = Gen.Char['\u0001', '퟿'].Array[0, 12].Select(chars => new string(chars)); + Gen nearMiss = Gen.OneOf("--version", "--VERSION", "-v", "--help", "domain", "init", "", " "); + Gen vectors = Gen.OneOf(anyText, nearMiss).Array[0, 4] + .Where(arguments => !(arguments.Length == 1 && arguments[0] == "--version")); + + vectors.Sample( + arguments => + { + CommandResult run = HostProcess.Run(arguments); + + Assert.Equal(2, run.ExitCode); + Assert.Empty(run.Stdout); + Assert.True(run.Stderr.Length > 0 && run.Stderr[^1] == (byte)'\n', "stderr does not end with a line feed"); + Assert.Equal(1, run.Stderr.Count(b => b == (byte)'\n')); + Assert.True(run.Stderr.AsSpan().StartsWith("product: "u8), "stderr does not begin with 'product: '"); + }, + iter: 100, + print: arguments => "[" + string.Join(", ", arguments.Select(a => "\"" + a + "\"")) + "]"); + } +} diff --git a/tests/ProductCli.Host.Tests/HostProcess.cs b/tests/ProductCli.Host.Tests/HostProcess.cs new file mode 100644 index 00000000..1c6b02db --- /dev/null +++ b/tests/ProductCli.Host.Tests/HostProcess.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ProductCli.Testing; + +namespace ProductCli.Host.Tests; + +/// Runs the built host, `product`, as a process, and keeps what it wrote (ADR-0006). +internal static class HostProcess +{ + private static readonly string Assembly = Path.Combine(AppContext.BaseDirectory, "ProductCli.Host.dll"); + + internal static CommandResult Run(params string[] arguments) + { + List command = ["exec", Assembly]; + command.AddRange(arguments); + return Command.Run("dotnet", command); + } +} diff --git a/tests/ProductCli.Host.Tests/LayerTableTests.cs b/tests/ProductCli.Host.Tests/LayerTableTests.cs new file mode 100644 index 00000000..cd00ab26 --- /dev/null +++ b/tests/ProductCli.Host.Tests/LayerTableTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// +/// The layers the built assemblies declare are ADR-0002 D1's table. Read from +/// each assembly's metadata, where DD0001 reads them, not from a project file. +/// +public sealed class LayerTableTests +{ + /// ADR-0002 D1, from the bottom. + public static TheoryData Table => new() + { + { "ProductCli.Vocabulary", 0 }, + { "ProductCli.Canonical", 1 }, + { "ProductCli.Shapes", 2 }, + { "ProductCli.Draft", 3 }, + { "ProductCli.Simulation", 4 }, + { "ProductCli.Runner", 5 }, + { "ProductCli.Session", 6 }, + { "ProductCli.Host", 7 }, + }; + + /// Each assembly's [assembly: ArchLayer(n)] is its row of the table. + [Theory] + [MemberData(nameof(Table))] + public void TheAssemblyDeclaresItsLayer(string assembly, int layer) + { + Assert.Equal([layer], DeclaredLayers(Path.Combine(AppContext.BaseDirectory, assembly + ".dll"))); + } + + private static List DeclaredLayers(string path) + { + using FileStream file = File.OpenRead(path); + using PEReader pe = new(file); + MetadataReader metadata = pe.GetMetadataReader(); + + List layers = []; + foreach (CustomAttributeHandle handle in metadata.GetAssemblyDefinition().GetCustomAttributes()) + { + CustomAttribute attribute = metadata.GetCustomAttribute(handle); + if (attribute.Constructor.Kind != HandleKind.MethodDefinition) + { + continue; + } + + TypeDefinition type = metadata.GetTypeDefinition( + metadata.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType()); + if (metadata.GetString(type.Namespace) == "DecisionDriven" && metadata.GetString(type.Name) == "ArchLayerAttribute") + { + // The value blob: the prolog 0x0001, then the one int32 argument. + BlobReader value = metadata.GetBlobReader(attribute.Value); + Assert.Equal(1, value.ReadUInt16()); + layers.Add(value.ReadInt32()); + } + } + + return layers; + } +} diff --git a/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj b/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj index 40b45ae8..9844f93e 100644 --- a/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj +++ b/tests/ProductCli.Host.Tests/ProductCli.Host.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/ProductCli.Host.Tests/VerbTests.cs b/tests/ProductCli.Host.Tests/VerbTests.cs new file mode 100644 index 00000000..bb6c98e1 --- /dev/null +++ b/tests/ProductCli.Host.Tests/VerbTests.cs @@ -0,0 +1,63 @@ +using System.Text; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// +/// Every argument vector but `--version` exits 2, with one line on stderr that +/// says where its verb arrives. The lines are for a person, not a format; the +/// test holds them so a change to one is a reviewed change. +/// +public sealed class VerbTests +{ + /// Each verb of the definition's chapter 3 names the step of the order of work it arrives in. + [Theory] + [InlineData("init", 4)] + [InlineData("product", 4)] + [InlineData("domain", 4)] + [InlineData("diff", 4)] + [InlineData("proposal", 5)] + [InlineData("view", 5)] + [InlineData("mcp", 5)] + [InlineData("decider", 6)] + [InlineData("projector", 6)] + [InlineData("primitive", 6)] + [InlineData("scenario", 7)] + [InlineData("conformance", 7)] + [InlineData("question", 8)] + public void AVerbNamesTheStepItArrivesIn(string verb, int step) + { + AssertRefused( + HostProcess.Run(verb, "--product", "shop"), + $"product: '{verb}' arrives in step {step} of the order of work (D1.25)\n"); + } + + /// An argument that is no verb is named as such, escaped to one printable line. + [Fact] + public void AnArgumentThatIsNoVerbIsNamed() + { + AssertRefused(HostProcess.Run("nodes\nlist"), "product: 'nodes\\u000alist' is not a verb\n"); + } + + /// No argument at all is refused, and the line says what this build has. + [Fact] + public void NoArgumentIsRefused() + { + AssertRefused(HostProcess.Run(), "product: no verb given; this build has only --version (step 0 of the order of work, D1.25)\n"); + } + + /// The version flag takes nothing after it. + [Fact] + public void VersionWithAnArgumentIsRefused() + { + AssertRefused(HostProcess.Run("--version", "--json"), "product: --version takes no arguments\n"); + } + + private static void AssertRefused(CommandResult run, string line) + { + Assert.Equal(2, run.ExitCode); + Assert.Empty(run.Stdout); + Assert.Equal(Encoding.ASCII.GetBytes(line), run.Stderr); + } +} diff --git a/tests/ProductCli.Host.Tests/VersionTests.cs b/tests/ProductCli.Host.Tests/VersionTests.cs new file mode 100644 index 00000000..4c136e89 --- /dev/null +++ b/tests/ProductCli.Host.Tests/VersionTests.cs @@ -0,0 +1,28 @@ +using System.Text; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// `product --version`, the host's one behaviour in slice 0 (D1.25), read as bytes. +public sealed class VersionTests +{ + /// + /// Stdout is exactly product <version> (commit <sha>) and a + /// line feed; stderr is empty; the exit code is 0. The version is the + /// build's, and the commit is the checkout's HEAD, read from git. + /// + [Fact] + public void VersionWritesTheVersionAndTheSourceCommitAsOneLine() + { + CommandResult git = Command.Run("git", ["rev-parse", "HEAD"]); + Assert.Equal(0, git.ExitCode); + string commit = Encoding.ASCII.GetString(git.Stdout).Trim(); + + CommandResult run = HostProcess.Run("--version"); + + Assert.Equal(0, run.ExitCode); + Assert.Equal(Encoding.ASCII.GetBytes($"product {Repository.Version} (commit {commit})\n"), run.Stdout); + Assert.Empty(run.Stderr); + } +} From ceb7226625dba590fa143e7793e967382b290906 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:41:00 +0000 Subject: [PATCH 09/14] test(build): the build policy's planted failures, built on every run The plants of #167 are permanent tests rather than commits shown red and removed, as approved there: every commit that reaches main builds green, and these are the evidence, on every run. tests/fixtures/build-policy/ holds a library probe, checked as product code at src/ProductCli.PolicyProbe/. BuildPolicyTests builds it clean, then once per plant, and reads the error in the build's output: 1. a PackageVersion with no Adr=: PCLI0001; 2. a PackageVersion citing Adr="9999": PCLI0002; 3. CsCheck, scoped to tests/, referenced from the library: PCLI0003; 4. reflection-based JsonSerializer.Serialize in the library: IL2026 and IL3050, as errors (ADR-0003 D2). Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- tests/Harness/Probe.cs | 12 ++++ .../ProductCli.Host.Tests/BuildPolicyTests.cs | 66 +++++++++++++++++++ .../build-policy/Plants/ReflectionJson.cs | 12 ++++ tests/fixtures/build-policy/PolicyProbe.cs | 10 +++ .../ProductCli.PolicyProbe.csproj | 37 +++++++++++ .../build-policy/PublicAPI.Shipped.txt | 1 + .../build-policy/PublicAPI.Unshipped.txt | 0 7 files changed, 138 insertions(+) create mode 100644 tests/ProductCli.Host.Tests/BuildPolicyTests.cs create mode 100644 tests/fixtures/build-policy/Plants/ReflectionJson.cs create mode 100644 tests/fixtures/build-policy/PolicyProbe.cs create mode 100644 tests/fixtures/build-policy/ProductCli.PolicyProbe.csproj create mode 100644 tests/fixtures/build-policy/PublicAPI.Shipped.txt create mode 100644 tests/fixtures/build-policy/PublicAPI.Unshipped.txt diff --git a/tests/Harness/Probe.cs b/tests/Harness/Probe.cs index 0238e2ae..5dd4215a 100644 --- a/tests/Harness/Probe.cs +++ b/tests/Harness/Probe.cs @@ -40,6 +40,18 @@ internal static CommandResult Layering(int? layer, string? references) return Build("tests/fixtures/layering/ProductCli.Probe.csproj", properties); } + /// The build-policy probe, with one plant (none if null). + internal static CommandResult BuildPolicy(string? plant) + { + Dictionary properties = []; + if (plant is not null) + { + properties["ProbePlant"] = plant; + } + + return Build("tests/fixtures/build-policy/ProductCli.PolicyProbe.csproj", properties); + } + private static CommandResult Build(string probe, Dictionary properties) { string artifacts = Path.Combine(Path.GetTempPath(), "productcli-probe-" + Guid.NewGuid().ToString("N")); diff --git a/tests/ProductCli.Host.Tests/BuildPolicyTests.cs b/tests/ProductCli.Host.Tests/BuildPolicyTests.cs new file mode 100644 index 00000000..fe88df46 --- /dev/null +++ b/tests/ProductCli.Host.Tests/BuildPolicyTests.cs @@ -0,0 +1,66 @@ +using System; +using ProductCli.Testing; +using Xunit; + +namespace ProductCli.Host.Tests; + +/// +/// The build policy's failing cases, built on every run (ADR-0003 D2, +/// ADR-0013 D4). Each plant is a property of the probe under +/// tests/fixtures/build-policy/; the test reads the error in the build's +/// output. The probe stands in for a library under src/. +/// +public sealed class BuildPolicyTests +{ + /// With no plant, the probe builds: each failure below is the plant's, not the probe's. + [Fact] + public void TheProbeBuildsWithNoPlant() + { + CommandResult build = Probe.BuildPolicy(plant: null); + + Assert.True(build.ExitCode == 0, build.Output); + } + + /// A register entry with no Adr= fails the build with PCLI0001. + [Fact] + public void AnEntryWithoutAdrFails() + { + AssertFails( + Probe.BuildPolicy("no-adr"), + "error PCLI0001: PackageVersion 'ProductCli.PlantedPackage' carries no Adr="); + } + + /// A register entry citing an ADR that does not exist fails the build with PCLI0002. + [Fact] + public void AnEntryCitingNoAdrFileFails() + { + AssertFails( + Probe.BuildPolicy("dangling-adr"), + "error PCLI0002: PackageVersion 'ProductCli.PlantedPackage' cites Adr=\"9999\", and docs/decisions/ holds no ADR-9999-*.md"); + } + + /// A test-only package referenced from product code fails the build with PCLI0003. + [Fact] + public void ATestOnlyPackageInProductCodeFails() + { + AssertFails( + Probe.BuildPolicy("out-of-scope"), + "error PCLI0003: ProductCli.PolicyProbe (src/ProductCli.PolicyProbe/) references 'CsCheck', whose register entry is scoped to tests/"); + } + + /// Reflection-based System.Text.Json in a library fails the build with the trim and AOT analyzers' errors. + [Fact] + public void ReflectionBasedJsonInALibraryFails() + { + CommandResult build = Probe.BuildPolicy("reflection-json"); + + AssertFails(build, "error IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)'"); + AssertFails(build, "error IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)'"); + } + + private static void AssertFails(CommandResult build, string error) + { + Assert.NotEqual(0, build.ExitCode); + Assert.Contains(error, build.Output, StringComparison.Ordinal); + } +} diff --git a/tests/fixtures/build-policy/Plants/ReflectionJson.cs b/tests/fixtures/build-policy/Plants/ReflectionJson.cs new file mode 100644 index 00000000..00f77a95 --- /dev/null +++ b/tests/fixtures/build-policy/Plants/ReflectionJson.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace ProductCli.PolicyProbe.Plants; + +/// +/// The planted failure of ADR-0003 D2: reflection-based serialisation in a +/// library. The AOT and trim analyzers must refuse it, as errors. +/// +internal static class ReflectionJson +{ + internal static string Serialise(object value) => JsonSerializer.Serialize(value); +} diff --git a/tests/fixtures/build-policy/PolicyProbe.cs b/tests/fixtures/build-policy/PolicyProbe.cs new file mode 100644 index 00000000..35cf7732 --- /dev/null +++ b/tests/fixtures/build-policy/PolicyProbe.cs @@ -0,0 +1,10 @@ +namespace ProductCli.PolicyProbe; + +/// +/// The probe's one source file. It places the compilation in this directory, +/// so the compiler reads the repository's .editorconfig and .globalconfig as +/// it does for a library under src/. +/// +internal static class PolicyProbe +{ +} diff --git a/tests/fixtures/build-policy/ProductCli.PolicyProbe.csproj b/tests/fixtures/build-policy/ProductCli.PolicyProbe.csproj new file mode 100644 index 00000000..f2d1b8cb --- /dev/null +++ b/tests/fixtures/build-policy/ProductCli.PolicyProbe.csproj @@ -0,0 +1,37 @@ + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/build-policy/PublicAPI.Shipped.txt b/tests/fixtures/build-policy/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/tests/fixtures/build-policy/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/tests/fixtures/build-policy/PublicAPI.Unshipped.txt b/tests/fixtures/build-policy/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b From 0d2836d83a11c329cda896b6dbbc9bddac6dc854 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:41:00 +0000 Subject: [PATCH 10/14] ci(gates): build-test and the AOT publish and smoke matrix ADR-0003 D3 and D5 in gates.yml, on every pull request. - build-test (ubuntu-24.04): dotnet build -c Release, warnings as errors, then dotnet test -c Release, the probes included. - aot (), one job per supported runtime identifier, each on its own operating system's runner, since Native AOT does not cross between them: linux-x64 on ubuntu-24.04, linux-arm64 on ubuntu-24.04-arm, osx-arm64 on macos-15, win-x64 on windows-2025. Each checks the RID is in ProductCliRuntimeIdentifiers, runs dotnet publish -c Release -r , runs the smoke test on the binary, and reports its size. - eng/gates/smoke.sh : --version must write exactly the expected line (compared with cmp), nothing to stderr, and exit 0; "domain" must exit 2 with nothing on stdout. selftest gains its plants: another version, another commit, a CRLF, exit 1, output on stderr, a verb exiting 0, and a verb writing to stdout. Both jobs check out the pull request's head, so the stamped commit is the head under review. actions/setup-dotnet is pinned to v6.1.0, 0f4a354dd5547a28c70b7853dbd95d5b67886e73, and reads global.json. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .github/workflows/gates.yml | 78 +++++++++++++++++++++++++++++++++++++ eng/gates/selftest.sh | 33 ++++++++++++++++ eng/gates/smoke.sh | 57 +++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100755 eng/gates/smoke.sh diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 5c81bd0c..1b249d52 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -14,6 +14,10 @@ concurrency: group: gates-${{ github.event.pull_request.number }} cancel-in-progress: true +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + jobs: commits: name: commits @@ -73,3 +77,77 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Each check fails on its plant and passes its clean case run: eng/gates/selftest.sh + + # The .NET build (ADR-0002, ADR-0003, ADR-0013). Checked out at the pull + # request's head, not GitHub's merge commit, so the commit the host stamps + # is the head that is reviewed. + build-test: + name: build-test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0 + with: + global-json-file: global.json + - name: Build, warnings as errors + run: dotnet build ProductCli.slnx -c Release + - name: Test, the layering and build-policy probes included + run: dotnet test -c Release --no-build + + # ADR-0003 D3, D5: the Native AOT publish of the host on every supported + # runtime identifier, each on a runner of its own operating system (Native + # AOT does not cross-compile between them), and the smoke test of the + # published binary. + aot: + name: aot (${{ matrix.rid }}) + strategy: + fail-fast: false + matrix: + include: + - rid: linux-x64 + os: ubuntu-24.04 + - rid: linux-arm64 + os: ubuntu-24.04-arm + - rid: osx-arm64 + os: macos-15 + - rid: win-x64 + os: windows-2025 + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0 + with: + global-json-file: global.json + - name: The runtime identifier is one the build policy supports + env: + RID: ${{ matrix.rid }} + run: | + supported=$(dotnet msbuild src/ProductCli.Host/ProductCli.Host.csproj -getProperty:ProductCliRuntimeIdentifiers) + echo "supported: $supported" + case ";$supported;" in + *";$RID;"*) ;; + *) echo "aot: $RID is not in ProductCliRuntimeIdentifiers"; exit 1 ;; + esac + - name: Publish, Native AOT + env: + RID: ${{ matrix.rid }} + run: dotnet publish src/ProductCli.Host/ProductCli.Host.csproj -c Release -r "$RID" -o "artifacts/publish/$RID" + - name: Smoke test of the published binary + env: + RID: ${{ matrix.rid }} + run: | + ext="" + if [ "$RID" = win-x64 ]; then ext=.exe; fi + binary="artifacts/publish/$RID/product$ext" + version=$(dotnet msbuild src/ProductCli.Host/ProductCli.Host.csproj -getProperty:Version) + eng/gates/smoke.sh "$binary" "$version" "$(git rev-parse HEAD)" + size=$(wc -c < "$binary" | tr -d ' ') + echo "aot: $RID: $binary is $size bytes" + echo "| $RID | \`product$ext\` | $size bytes |" >> "$GITHUB_STEP_SUMMARY" diff --git a/eng/gates/selftest.sh b/eng/gates/selftest.sh index 6b2da95c..68650144 100755 --- a/eng/gates/selftest.sh +++ b/eng/gates/selftest.sh @@ -230,6 +230,39 @@ r=$(toolchain_root root-second-pin) cp "$repo/global.json" "$r/tests/global.json" expect_fail "no-toolchain: a second global.json" "no-toolchain: tests/global.json is a second global.json" "$gates/no-toolchain.sh" "$r" +# --- smoke ------------------------------------------------------------------- + +# Each stub stands in for a published binary. The clean one behaves as the +# host must; each plant breaks one thing the smoke test reads. +sha=0123456789abcdef0123456789abcdef01234567 +stub() { # stub : an executable script + local f="$work/stub-$1" + printf '#!/usr/bin/env bash\n%s\n' "$2" > "$f" + chmod +x "$f" + echo "$f" +} +good_version="printf 'product 1.2.3 (commit $sha)\\n'" +good_domain="echo \"product: 'domain' arrives in step 4\" >&2; exit 2" +body() { # body + printf 'if [ "$1" = --version ]; then %s; else %s; fi' "$1" "$2" +} + +expect_pass "smoke" "$gates/smoke.sh" "$(stub clean "$(body "$good_version" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: another version" "smoke: --version wrote other bytes than expected" \ + "$gates/smoke.sh" "$(stub version "$(body "printf 'product 1.2.4 (commit $sha)\\n'" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: another commit" "smoke: --version wrote other bytes than expected" \ + "$gates/smoke.sh" "$(stub commit "$(body "printf 'product 1.2.3 (commit ffff)\\n'" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: a CRLF line end" "smoke: --version wrote other bytes than expected" \ + "$gates/smoke.sh" "$(stub crlf "$(body "printf 'product 1.2.3 (commit $sha)\\r\\n'" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: --version exits 1" "smoke: --version exited 1, not 0" \ + "$gates/smoke.sh" "$(stub exit1 "$(body "$good_version; exit 1" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: --version writes to stderr" "smoke: --version wrote to stderr" \ + "$gates/smoke.sh" "$(stub stderr "$(body "$good_version; echo warning >&2" "$good_domain")")" 1.2.3 "$sha" +expect_fail "smoke: a verb exits 0" "smoke: 'domain' exited 0, not 2" \ + "$gates/smoke.sh" "$(stub verb0 "$(body "$good_version" "exit 0")")" 1.2.3 "$sha" +expect_fail "smoke: a verb writes to stdout" "smoke: 'domain' wrote to stdout" \ + "$gates/smoke.sh" "$(stub verbout "$(body "$good_version" "echo nodes; exit 2")")" 1.2.3 "$sha" + if [ "$failed" -ne 0 ]; then echo "selftest: a check did not behave as its plant requires" exit 1 diff --git a/eng/gates/smoke.sh b/eng/gates/smoke.sh new file mode 100755 index 00000000..0252872b --- /dev/null +++ b/eng/gates/smoke.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# The smoke test of a published binary (ADR-0003 D3): what it writes, byte for byte, and how it exits. +# +# --version writes exactly "product (commit )\n" +# to stdout, nothing to stderr, and exits 0; +# domain writes nothing to stdout and exits 2. +# +# The expected line is built here, from the arguments, and compared with cmp, +# so a CRLF, a trailing space or a missing line feed fails. +# +# Usage: eng/gates/smoke.sh +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: eng/gates/smoke.sh " >&2 + exit 1 +fi +binary=$1 version=$2 commit=$3 + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +failed=0 +fail() { + echo "smoke: $1" + failed=1 +} + +printf 'product %s (commit %s)\n' "$version" "$commit" > "$work/expected" + +status=0 +"$binary" --version > "$work/stdout" 2> "$work/stderr" || status=$? +if [ "$status" -ne 0 ]; then + fail "--version exited $status, not 0" +fi +if ! cmp -s "$work/expected" "$work/stdout"; then + fail "--version wrote other bytes than expected:" + echo " expected: $(od -An -c "$work/expected" | tr -s ' ' | tr -d '\n')" + echo " actual: $(od -An -c "$work/stdout" | tr -s ' ' | tr -d '\n')" +fi +if [ -s "$work/stderr" ]; then + fail "--version wrote to stderr: $(head -c 200 "$work/stderr")" +fi + +status=0 +"$binary" domain > "$work/stdout" 2> "$work/stderr" || status=$? +if [ "$status" -ne 2 ]; then + fail "'domain' exited $status, not 2" +fi +if [ -s "$work/stdout" ]; then + fail "'domain' wrote to stdout: $(head -c 200 "$work/stdout")" +fi + +if [ "$failed" -ne 0 ]; then + exit 1 +fi +echo "smoke: $binary prints 'product $version (commit $commit)' and exits 0; 'domain' exits 2" From 0bbb9ff7a880d3daa1c8d967b1aabec0039b3baf Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:41:06 +0000 Subject: [PATCH 11/14] docs: AGENTS.md and README for the build AGENTS.md: - the build, test, publish and smoke commands; - the layer table of ADR-0002 D1, and the projects that do not exist yet; - the package rule: a new package needs an ADR and a PackageVersion with Adr=, and Scope= when test-only (ADR-0004 as amended by ADR-0013), with the PCLI errors that enforce it; - the gates table, with build-test, aot () and the new no-toolchain rule; - that the host's exit-2 line is not a format and nothing parses it. README.md: how to build, test and publish, and the supported runtime identifiers. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- AGENTS.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++----- README.md | 34 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a66f1264..61fe1cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,85 @@ ADRs and its tests**, and its release descriptors (D1.24). - `docs/traceability/` holds one record per agent session (ADR-0007 D2). - `releases/` holds the release descriptors (ADR-0007 D3). - `eng/gates/` holds the checks `.github/workflows/gates.yml` runs. +- `src/` holds the eight layer projects, `tests/` their tests and the probes + under `tests/fixtures/` (slice 0, D1.25, #167). -There is no source code yet. Slice 0 (ADR-0002, ADR-0003, ADR-0004) brings -the eight layer projects, the layering analyzer, the AOT publish gate and the -package register, in its own pull request. +## Build, test, publish + +The SDK is pinned in `global.json` (`10.0.401`, `rollForward: latestFeature`), +with Microsoft.Testing.Platform as the test runner. Warnings are errors. + +```bash +dotnet build ProductCli.slnx -c Release +``` + +```bash +dotnet test -c Release --no-build +``` + +```bash +dotnet publish src/ProductCli.Host/ProductCli.Host.csproj -c Release -r linux-x64 -o artifacts/publish/linux-x64 +``` + +```bash +eng/gates/smoke.sh artifacts/publish/linux-x64/product "$(dotnet msbuild src/ProductCli.Host/ProductCli.Host.csproj -getProperty:Version)" "$(git rev-parse HEAD)" +``` + +- **Build the solution before testing.** The layering and build-policy probes + are built by the tests with `dotnet build`, against the solution's outputs. +- **Native AOT publishes on its own operating system only.** The supported + runtime identifiers are `ProductCliRuntimeIdentifiers` in + `Directory.Build.props`: `linux-x64`, `linux-arm64`, `osx-arm64` and + `win-x64` (ADR-0003 D5). CI publishes each one on its own runner. +- **`product --version`** is the host's one behaviour in slice 0. Every other + argument exits 2 with one line on stderr naming the step of the order of + work its verb arrives in. **That line is not a format**: it is for a person, + nothing may parse it, and it changes as verbs are built. + +## The layers + +ADR-0002 D1. Each project references only the one below it, Varve and the +BCL. `ArchLayer` is set in each project file, `ArchFamily` is `ProductCli`, +and DD0001 (`DecisionDriven.Analyzers`, ADR-0013) fails the build on a +reference that is not strictly downward, and on a `ProductCli.*` project with +no layer (`.globalconfig`). + +| Layer | Project | Holds | +|---|---|---| +| 0 | `ProductCli.Vocabulary` | the generated node records, the `sdlc:` terms, the edition | +| 1 | `ProductCli.Canonical` | the RDFC graph, the exporters, the `dpp/1` record; references `Varve.Rdf` | +| 2 | `ProductCli.Shapes` | the shape-engine contract, over Varve | +| 3 | `ProductCli.Draft` | the draft-store contract, over Varve; DPP commits | +| 4 | `ProductCli.Simulation` | derivation, rule evaluation, findings | +| 5 | `ProductCli.Runner` | the wire protocol, conformance | +| 6 | `ProductCli.Session` | the verbs, the phase gate | +| 7 | `ProductCli.Host` | the composition root and the only executable, `product` (Native AOT) | + +Every library is `IsAotCompatible` and `IsTrimmable`, with the AOT and trim +analyzers' diagnostics as errors (ADR-0003 D2), and has a public API baseline, +`PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`, beside its project +file (ADR-0002 D3). A new public member is a line in that diff. + +`ProductCli.Vocabulary.Generator`, `ProductCli.Workbench` and +`ProductCli.Workbench.Contracts` do not exist yet: each arrives with the slice +that needs it. + +## Packages + +`Directory.Packages.props` is the register (ADR-0004, as amended by +ADR-0013 D4). Versions are central: no project file carries one. + +- **A new package needs an ADR**, and a `PackageVersion` entry whose `Adr=` + names it. A test-only entry also carries `Scope="tests/"`. +- **The build fails** on an entry without `Adr=` (`PCLI0001`), on an `Adr=` + naming no file in `docs/decisions/` (`PCLI0002`), and on a reference from + outside an entry's `Scope=` (`PCLI0003`). `BuildPolicyTests` builds each + failing case on every run. +- **Versions are resolved from nuget.org**, never from memory, and the date is + recorded in the commit that adds or changes them. +- Every `DecisionDriven.Analyzers` rule is on, as an error. The first public + interface needs a decision set in that package's format, so that + `[Contract]` can cite it (ADR-0013, Consequences). **The Rust workspace is frozen at the tag `rust-final`** (commit `70395df`). Nothing at the tag changes. Anything the rewrite reads from the Rust tool — the @@ -56,8 +131,8 @@ interim rule of Writ ADR 0008); `docs/decisions/README.md` states the rule. were. - ADR-0001 to ADR-0012 are held byte for byte against `docs/decisions/collected-2026-11-03.md`; `adr-text` checks it. -- Every `PackageReference` carries `Adr=` naming the ADR that admits it - (ADR-0004), from slice 0. +- Every `PackageVersion` in `Directory.Packages.props` carries `Adr=` naming + the ADR that admits it (ADR-0004 D1, as amended by ADR-0013 D4). ## Gates @@ -70,8 +145,10 @@ is a Bash script in `eng/gates/` and runs locally the same way: | `signatures` | `eng/gates/signatures.sh ` | GitHub does not report a commit of the pull request as verified | | `adr-shape` | `eng/gates/adr-shape.sh [dir]` | an ADR lacks its number, status, alternatives or consequences, or an unexpected file is in `docs/decisions/` | | `adr-text` | `eng/gates/adr-text.sh [dir]` | ADR-0001 to ADR-0012 differ by a byte from their sections of the collected file, with the Draft 4 notes | -| `no-toolchain` | `eng/gates/no-toolchain.sh [root]` | a Rust, Node or Python toolchain file is at the root | -| `selftest` | `eng/gates/selftest.sh` | any check above passes on the case planted for it | +| `no-toolchain` | `eng/gates/no-toolchain.sh [root]` | a Rust, Node or Python toolchain file is anywhere in the tree, or `global.json` is missing, pins another SDK, roll-forward or test runner, or has a second copy below the root | +| `selftest` | `eng/gates/selftest.sh` | any check above, or `smoke.sh`, passes on the case planted for it | +| `build-test` | `dotnet build` then `dotnet test`, both `-c Release` | the build has a warning or error, or a test fails | +| `aot ()` | `dotnet publish -r `, then `eng/gates/smoke.sh ` | the Native AOT publish fails on that runtime's own runner, or the binary's `--version` bytes, its exit code, or its refusal of a verb are not as required | `selftest` plants a failing case for every check in a scratch repository on the runner, so each run shows that each check still fails when it should. @@ -145,7 +222,8 @@ verb's result is rejected in review. - **A suppression of any rule** (ADR-0007 D6). - **Copied Rust code.** The Rust tool is read at `rust-final` for its behaviour, and its bytes are never carried. -- **A package without `Adr=`**, or a version taken from memory (ADR-0004). +- **A package without `Adr=`**, or a version taken from memory (ADR-0004, as + amended by ADR-0013). - **A commit** unsigned, without `Refs #N` or a DCO sign-off, or naming a model. - **Rewriting or deleting a tag or a branch**, force-pushing `main`, or changing anything at `rust-final`. diff --git a/README.md b/README.md index a28eebc9..19de7597 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,40 @@ The product is defined in decision-driven-design: This repository holds the tool, its ADRs (`docs/decisions/`) and its tests. Agents start at [`AGENTS.md`](AGENTS.md). +## Build and test + +The repository is the skeleton of slice 0 (D1.25): the eight layer projects +under `src/`, their tests under `tests/`, and a host whose only behaviour is +`product --version`. It needs the .NET SDK that `global.json` pins (10.0.401, +or a later feature band of 10.0) and nothing else. + +```bash +dotnet build ProductCli.slnx -c Release +``` + +```bash +dotnet test -c Release --no-build +``` + +The tests build probe projects with `dotnet build` against the solution's +outputs, so build the solution first. + +To publish the native binary, `product`, for your platform: Native AOT +compiles for the operating system it runs on, and needs that platform's +native toolchain (clang on Linux, Xcode on macOS, the Visual C++ build tools +on Windows). + +```bash +dotnet publish src/ProductCli.Host/ProductCli.Host.csproj -c Release -r linux-x64 -o artifacts/publish/linux-x64 +``` + +```bash +artifacts/publish/linux-x64/product --version +``` + +The supported runtime identifiers are `linux-x64`, `linux-arm64`, `osx-arm64` +and `win-x64`. + ## The Rust tool The Rust workspace is frozen at the tag From 4a6303f068bc05fb65cbab3109d0c6f8f05a6c81 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:47:39 +0000 Subject: [PATCH 12/14] releases: v0.8.0-preview.2, the repository skeleton The descriptor of slice 0 (ADR-0007 D3), on D1.25 at decision-driven-design 799cbe8, ADR-0002, 0003, 0004 and 0013, and Varve.Rdf 0.1.0-preview.15. No sdlc: edition: slice 0 needs none. The Native AOT binary sizes, from gates.yml run 41638171084 at 0bbb9ff: linux-x64 1,829,032 bytes; linux-arm64 1,914,896; osx-arm64 1,766,640; win-x64 1,692,672. accepted-by is left for the maintainer, who adds it with ADR-0013's acceptance in his own commit. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- releases/v0.8.0-preview.2.yaml | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 releases/v0.8.0-preview.2.yaml diff --git a/releases/v0.8.0-preview.2.yaml b/releases/v0.8.0-preview.2.yaml new file mode 100644 index 00000000..983de9a8 --- /dev/null +++ b/releases/v0.8.0-preview.2.yaml @@ -0,0 +1,66 @@ +format: 1 +version: v0.8.0-preview.2 +title: The repository skeleton — eight layers, one native binary +date: 2026-11-03 +basis: + - issue: 167 + - definition: mindovermachine-dev/decision-driven-design@799cbe84f2a010fb5b65a687eb253666948c73c8 + - definition-ruling: D1.25 + - sdlc-edition: none (slice 0 needs no edition) + - varve: Varve.Rdf 0.1.0-preview.15 + - adr: "0002" + - adr: "0003" + - adr: "0004" + - adr: "0013" +summary: | + ### The repository skeleton (#167) + + Step 0 of the order of work, D1.25. The repository builds the product as + ADR-0002, ADR-0003 and ADR-0004 rule, and ships its first binary, + `product`, whose only behaviour is `product --version`. It adds no + vocabulary, no node kind and no product behaviour, and needs no `sdlc:` + edition. + + #### Added + + - **The eight layer projects** of ADR-0002 D1, `ProductCli.Vocabulary` + (0) to `ProductCli.Host` (7), each referencing only the layer below it. + Each library has a public API baseline and is AOT-compatible and + trimmable, with the AOT and trim analyzers' diagnostics as errors. + - **The layering rule**, DD0001 of `DecisionDriven.Analyzers`, with every + project placed. One test per layer builds a probe that references the + layer above and reads DD0001 in the compiler's output. + - **The package register**, `Directory.Packages.props`. Every entry names + the ADR that admits it, and the build fails when one does not, when the + ADR does not exist, or when a test-only package is referenced from + product code. ADR-0013 admits the analyzers, names the test stack + (xunit v3 on Microsoft.Testing.Platform, and CsCheck), and amends + ADR-0004 D1's wording: the register entry is the `PackageVersion`. + - **`product`**, one Native AOT executable per supported runtime + identifier. `product --version` prints + `product 0.8.0-preview.2 (commit )` and exits 0. Every other + argument exits 2 with one line naming the step its verb arrives in. + - **CI on every pull request**: the build and tests with warnings as + errors, and the Native AOT publish and smoke test of the binary on each + runtime's own runner. + + | Runtime | Runner | Binary size | + |---|---|---| + | `linux-x64` | `ubuntu-24.04` | 1,829,032 bytes | + | `linux-arm64` | `ubuntu-24.04-arm` | 1,914,896 bytes | + | `osx-arm64` | `macos-15` | 1,766,640 bytes | + | `win-x64` | `windows-2025` | 1,692,672 bytes (`product.exe`) | + + #### Varve + + `Varve.Rdf` and `Varve.Turtle` `0.1.0-preview.15` are admitted on + evidence: they reproduce decision-driven-design's published RDFC-1.0 + graph hash of `acme-shop.ttl`, + `sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b`, + byte for byte. + + #### Changed + + - **`no-toolchain`** now requires exactly one toolchain, .NET, pinned in + `global.json`. A Rust, Node or Python toolchain file anywhere in the + tree fails it, not only one at the root. From c81271474341dbf0aa1a66933d7968f3d2715b4e Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 3 Nov 2026 12:47:39 +0000 Subject: [PATCH 13/14] docs(traceability): the session record for #167 The prompt and the approval, verbatim; what was read; what the session found, including the analyzer configuration the SDK did not pass to a library with no source; the commits with GitHub's verification; every commit checked on its own; gates.yml run 41638171084 on every job, with the AOT binary sizes; the planted failures' errors; and the deviations from the plan, with their reasons. Refs #167 Signed-off-by: Emil Okkels Klein Claude-Session: https://claude.ai/code/session_01NbWSYTf9GvhrtXDW5cHcYg --- .../2026-11-03-issue-167-slice-0.md | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/traceability/2026-11-03-issue-167-slice-0.md diff --git a/docs/traceability/2026-11-03-issue-167-slice-0.md b/docs/traceability/2026-11-03-issue-167-slice-0.md new file mode 100644 index 00000000..28042773 --- /dev/null +++ b/docs/traceability/2026-11-03-issue-167-slice-0.md @@ -0,0 +1,314 @@ +# Slice 0: the repository skeleton (D1.25) + +> **Recorded contemporaneously**, by the session that did the work, under +> ADR-0007 D2, in the shape of Writ's `docs/traceability/` records (Writ ADR +> 0008). The maintainer's messages are verbatim. The transcript is held by the +> maintainer. + +| | | +|---|---| +| **Issue** | [#167](https://github.com/mindovermachine-dev/product-cli/issues/167), opened by this session | +| **Pull request** | [#168](https://github.com/mindovermachine-dev/product-cli/pull/168) | +| **Date** | 2026-11-03 | +| **Tool** | Claude Code, a cloud session | +| **Model** | not recorded (ADR-0007 D1) | +| **Session identifier** | `session_01NbWSYTf9GvhrtXDW5cHcYg` | +| **Branch** | `claude/affectionate-darwin-89uq7g`, from `main` at `53bd54758ba71f7a810f91ddf3a6c83d5e653145` | +| **Identity** | `Claude (Emil) `, SSH-signed with key `SHA256:xDu5EyJd05ROum1g+8Aj1Up3MjE6RbHnI70/xNHljDY` | +| **Definition** | decision-driven-design `799cbe84f2a010fb5b65a687eb253666948c73c8`, D1.25 | + +## The prompts + +The first message: + +> # Session prompt: slice 0, the repository skeleton (D1.25) +> +> You work in `mindovermachine-dev/product-cli`. This session holds Emil's signing key. Every commit is signed and must show as verified on GitHub. +> +> **Stop and ask before you start if any of these is false:** +> - the Rust-removal PR, #166 (D1.24), is merged: `main` is at `53bd547` or a descendant of it. It holds `AGENTS.md`, `CLAUDE.md`, `README.md`, `LICENSE`, `NOTICE`, `docs/`, `releases/`, `.github/workflows/gates.yml` and `eng/gates/`, and nothing else; +> - every new commit must pass the `gates.yml` checks already on `main` (`commits`, `signatures`, `adr-shape`, `adr-text`, `no-toolchain`, `selftest`). This slice adds a .NET toolchain at the root, so `no-toolchain` must change in the same PR to allow it. Say so in the plan, with the exact rule that replaces it; +> - `docs/decisions/` holds ADR-0001 to ADR-0012; +> - decision-driven-design `main` carries D1.25, step 0 of the order of work, in `applications/sdlc/product-cli/product-definition.md`. Outside-in: the definition change lands first. Cite its commit. +> +> This slice needs no `sdlc:` edition and must not wait for one. It adds no vocabulary, no node kind and no product behaviour. +> +> ## Read first +> +> 1. The definition: chapters 6 and 7, D1.24 and D1.25. +> 2. In this repository: +> - ADR-0002, the layers; +> - ADR-0003, AOT and the CI gate; +> - ADR-0004, packages and the register; +> - ADR-0006, tests observe bytes; +> - ADR-0007, commits and sessions; +> - ADR-0010 D6. The workbench projects are *not* in this slice. +> 3. Varve `main`: +> - `Directory.Build.props`, `Directory.Build.targets` and `Directory.Packages.props`: the build policy, the `Adr=` register, `ArchFamily` and `ArchLayer`, and central package management; +> - `global.json` (SDK `10.0.401`, Microsoft.Testing.Platform); +> - `eng/`: the banned-symbol lists and the agent-review gates. +> +> Adopt the pattern. Copy nothing that names Varve's own packages or ADRs. +> 4. The `DecisionDriven.Analyzers` package. Its source is `Hafeok/decision-driven-analyzers`; Varve consumes `0.1.0-preview.10` from nuget.org under its ADR 0063. Confirm from its README and source that: +> - its layering rule (`DD0001`, from `ArchFamily` and `[ArchLayer]`) applies to a family other than Varve's; +> - it is AOT-neutral (an analyzer only). +> +> If either is false, stop and report. ADR-0002 D3 then calls for a local analyzer, which is a plan change. +> +> ## Plan first +> +> Open the tracking issue, "Slice 0: the repository skeleton (D1.25)", and post the plan as a comment. Wait for Emil's approval. Write no files before then. +> +> The plan names: +> - every file; +> - every project and its layer number; +> - every package, with its version as resolved from nuget.org today, its `Adr=` and its scope; +> - the runtime identifiers; +> - the CI jobs; +> - each test, and what it observes. +> +> ## Deliverables (one PR, `Refs #`) +> +> **1. ADR-0013: build-time analyzers and the test stack.** ADR-0004 D2 admits Varve and "the test framework and the property-testing library once named in slice 0's plan". This ADR does three things. +> - It names the test stack: +> - xunit v3 on Microsoft.Testing.Platform, as Varve uses; +> - a property-testing library, with its alternatives: FsCheck or CsCheck, chosen by the plan with a reason. +> +> These are test-only packages and are not published with AOT. +> - It admits the analyzers ADR-0002 D3 requires: +> - `DecisionDriven.Analyzers`, for layering; +> - `Microsoft.CodeAnalysis.PublicApiAnalyzers`, for the baselines; +> - `Microsoft.CodeAnalysis.BannedApiAnalyzers`, if the plan adopts banned-symbol lists. +> - It states why each one is admitted, and that none ships in the binary. +> +> `Adr=` on each entry names ADR-0004 or ADR-0013, whichever admits it. +> +> **2. The build policy.** +> - **`global.json`**: SDK `10.0.401`, `rollForward: latestFeature`, with Microsoft.Testing.Platform as the test runner. +> - **`Directory.Build.props` and `Directory.Build.targets`:** +> - nullable on; +> - warnings as errors; +> - deterministic builds; +> - `IsAotCompatible` and `IsTrimmable` on every library, with the AOT and trim analyzers as errors (ADR-0003 D2); +> - `ArchFamily` set to `ProductCli`, and `ArchLayer` declared per project; +> - the public API baseline files required on every library. +> - **`Directory.Packages.props`:** +> - central versions; +> - every `PackageVersion` carries `Adr=`, and test-only entries also carry `Scope=`; +> - the build fails on a package without `Adr=`. Show the failing case. +> - **`Varve.*` packages** at `0.1.0-preview.15` or the latest preview on nuget.org today, whichever is resolved. Only those the skeleton references: `Varve.Rdf` at least, in `ProductCli.Canonical`. +> +> **3. The eight projects (ADR-0002 D1).** Each lives under `src/` with its test project under `tests/`. +> +> | Project | Layer | +> |---|---| +> | `ProductCli.Vocabulary` | 0 | +> | `ProductCli.Canonical` | 1 | +> | `ProductCli.Shapes` | 2 | +> | `ProductCli.Draft` | 3 | +> | `ProductCli.Simulation` | 4 | +> | `ProductCli.Runner` | 5 | +> | `ProductCli.Session` | 6 | +> | `ProductCli.Host` | 7, the composition root; the only executable | +> +> - Each project references only the projects below it, Varve and the BCL (D2). +> - Each library has its `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`, empty apart from what deliverable 5 needs. +> - `ProductCli.Vocabulary.Generator`, `ProductCli.Workbench` and `ProductCli.Workbench.Contracts` are **not** created. They arrive with the slices that need them. +> +> **4. The layering tests.** +> - For each layer, a test compiles a probe that references a layer above it, and asserts that `DD0001` (or the analyzer's actual ID) refuses it. The test observes the diagnostic in the compiler's output, not a return value of ours (ADR-0006). +> - One positive probe shows that a downward reference compiles. +> - If `DecisionDriven.Analyzers` provides a test harness, use it. Otherwise compile the probes with Roslyn in the test. +> +> **5. The host and the AOT gate (ADR-0003).** +> - **`ProductCli.Host`** publishes one Native AOT executable, `product`. Its only behaviour in this slice is `product --version`. That prints the product version and the build's source commit, read from assembly metadata stamped at build time, and exits 0. Every other argument exits 2 with a one-line message naming the slice the verb arrives in. This is not scaffolding: it is the smoke test's subject. +> - **The supported runtime identifiers** are recorded in the build policy (D5): `linux-x64`, `linux-arm64`, `osx-arm64` and `win-x64`. If one cannot be built on the available CI runners, the plan says which and why. +> - **CI**, as jobs added to `gates.yml` and running on every PR: +> - build and test, with warnings as errors; +> - for each runtime identifier, a native `dotnet publish -c Release -r ` on a runner of that OS. Native AOT does not cross-compile across operating systems; +> - for each runtime identifier, the smoke test runs the published binary, reads its stdout bytes, and checks the version line and exit code 0. +> +> Also check that a planted reflection-based `System.Text.Json` call in a library fails the build with the AOT analyzer error. Show the failing run, then remove the plant. +> +> **6. The Varve admission evidence (ADR-0004 D2).** +> +> The skeleton references a Varve preview that has not been checked from here. Earlier checks of the canonicaliser and of SHACL ran against Varve `main` at `c0a776b`. One test in `ProductCli.Canonical.Tests` does the following: +> - reads decision-driven-design's `applications/sdlc/what/examples/acme-shop.ttl`, vendored under `tests/fixtures/ddd/` at a stated commit, with the commit and the file's sha256 in a `SOURCE.md` beside it; +> - parses it with `Varve.Turtle` and canonicalises it with `Varve.Rdf`'s `RdfCanonicaliser`; +> - asserts that the sha256 of the canonical N-Quads equals `vectors.json`'s `graphHash`, `sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b`. Vendor `vectors.json` beside the example. +> +> This is a test of the package, not product behaviour, and it lives in the layer that will use it. If it fails, stop: that is a Varve issue first (ADR-0002 D5). +> +> **7. Updates.** +> - `AGENTS.md`: the build, test and publish commands, the layer table, and the rule that a new package needs an ADR and an `Adr=` entry. +> - `README.md`: build and test instructions. +> - `releases/`: the descriptor for this slice. +> - `docs/traceability/-issue--slice-0.md`. +> +> ## Rules +> +> - Commit identity: `Claude (Emil) `, signed. +> - Every commit carries `Refs #` and `Signed-off-by:`, and no model name anywhere. +> - Resolve every version from nuget.org, never from memory, and record in the plan the date you resolved it. +> - No suppression: no `NoWarn`, `#pragma warning disable` or `SuppressMessage`. A rule that is wrong is changed by ADR. +> - Nothing in this slice writes to Varve, Writ or decision-driven-design. A gap there is an issue in that repository, linked from the PR. +> - `main` is fast-forwarded only by Emil, from his machine, at the head he approved with an `Approved at ` comment on the PR. GitHub's merge button is not used: it would rewrite or add unsigned commits. +> +> ## Done when +> +> - `gates.yml` is green on every job of the PR head, the AOT matrix included. +> - Every planted failure has been shown failing and has been removed. +> - Every commit verifies on GitHub; paste the API verification for each SHA. +> - ADR-0013 is accepted, and the PR is merged by Emil. +> +> Report: +> - the merge SHA; +> - each commit's verification; +> - the package versions as resolved; +> - the AOT binary size per runtime identifier; +> - any deviation from this prompt, with its reason. +> +> ## Reconciliation +> +> If another session lands on `main` while you work, merge `main`, re-run every check, and record the reconciliation in the traceability record. If it touched the build policy, the package register or a layer, stop and ask. + +The second message, approving the plan posted on #167: + +> Approved, with these answers. +> +> 1. Agreed: build the probes with `dotnet build` under the real build files. +> 2. Accepted: all `DecisionDriven.Analyzers` rules are on, as errors. That is ADR-0002 D3's intent. ADR-0013 states the consequence: the first public interface needs a decision set in the package's format. +> 3. The wording is accepted: no analyzer assembly ships; the generator adds internal attribute types and one assembly attribute to each of our assemblies. +> 4. Enforce `Adr=` on `PackageVersion` only. ADR-0013 says that it amends ADR-0004 D1's wording, and names ADR-0004 as amended (ADR-0001 D3: no silent divergence). AGENTS.md follows. +> 5. Commit ADR-0013 as `Proposed`. I change it to `Accepted (date)` in my own signed commit, together with the descriptor's `accepted-by` line. +> 6. The verb-to-step table is right. The exit-2 message is not a format and is not read-forever. Say so in AGENTS.md. +> 7. Yes to permanent tests: `BuildPolicyTests` builds plants 1 to 4 on every run. So no plant commits on the PR branch. Every commit that reaches `main` builds green, and the permanent tests are the evidence. Paste one test run in the PR. +> 8. `0.8.0-preview.2`, agreed. +> +> After the PR is up I'll add the new jobs to `main`'s required checks. + +## What was read + +- **The definition**, decision-driven-design at `799cbe84f2a010fb5b65a687eb253666948c73c8`: chapters 3 (the verb table), 6 and 7, D1.24 and D1.25. +- **This repository** at `53bd547`: `AGENTS.md`, ADR-0002, 0003, 0004, 0006 and 0007, ADR-0010 D6, `docs/decisions/README.md`, `.github/workflows/gates.yml`, every script in `eng/gates/`, `releases/v0.8.0-preview.1.yaml`, #166, and #165's record. +- **Varve** `main` at `62c0afb`: `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `global.json`, `nuget.config`, `.editorconfig`, `eng/BannedSymbols*.txt`, the head of `eng/dependency-register.cs`, a test project and the CI's runners. At tag `v0.1.0-preview.15` (`6cdb402`): `RdfCanonicaliser`, `InMemoryDatasetBuilder`, `TurtleParser`, `TurtleOptions` and the canonicaliser's conformance harness, for the admission test's calls. +- **decision-driven-analyzers** at `main` (`cae65f3`) and at tag `v0.1.0-preview.10` (`a041cb2`): the README, `docs/rules/DD0001.md`, `LayerReferenceAnalyzer.cs`, `GeneratedAttributes.cs`, the package's project file, and the published `0.1.0-preview.10` nupkg's contents and props. +- **nuget.org** on 2026-11-03, 12:14 UTC: the versions and nuspecs of every package in the register, and of FsCheck for the comparison. + +## What the session found + +- **Every precondition held.** `main` was at `53bd547`, exactly, with the expected tree. #166 was merged. `docs/decisions/` held ADR-0001 to ADR-0012. decision-driven-design `main` was `799cbe8`, which carries D1.25. +- **DD0001 applies to any family**, from `ArchFamily` and each referenced assembly's `[assembly: ArchLayer]`, and the package is AOT-neutral: analyzers and build props only, no `lib/`. Its generator does compile internal attribute types into each assembly, which ADR-0013 D3 states. +- **The package ships no consumer test harness**, so the probes are built with `dotnet build` (approved, question 1). +- **Varve's preview predates the earlier checks.** `0.1.0-preview.15` is 120 commits before `c0a776b`, and no commit in between touches the canonicaliser or the Turtle parser. The admission test passes on the preview. +- **Today's `no-toolchain` would have passed a .NET root.** It listed only Rust, Node and Python files, at the root. It was tightened anyway, to the rule the plan stated. +- **The SDK found no `.editorconfig` or `.globalconfig` for a project without source.** Its discovery walks up from the project's `Compile` items at evaluation, and an empty library has none, so `dd_require_layer` and the IL severities did not reach the seven libraries. The probe caught it: an unplaced probe built green. `Directory.Build.props` now names both files for every project, with discovery off so no file is passed twice. +- **The native binary is written after `Publish`** by the ILC targets' own `CopyNativeBinary`, so the rename to `product` runs after that target. +- **GitHub's Windows runners check out with CRLF** by default, which would break the gate scripts under bash. `.gitattributes` makes every checkout LF. + +## What the session did + +- Opened #167 and posted the plan ([comment](https://github.com/mindovermachine-dev/product-cli/issues/167#issuecomment-6603551451)). It wrote no file until the approval above. +- Wrote ADR-0013 as `Proposed`, the build policy, the eight projects, the tests, the CI jobs, the gate changes, `AGENTS.md`, `README.md` and the release descriptor. +- Verified every commit on its own, before pushing: each was checked out into a clean worktree and run through `commits`, `adr-shape`, `adr-text`, `no-toolchain` and `selftest`, then, from the first commit with a solution, `dotnet build -c Release` and `dotnet test -c Release`. All passed. +- Opened the pull request. It merged nothing, tagged nothing and wrote no acceptance. +- Wrote nothing to Varve, Writ or decision-driven-design. No gap there was found, so no issue was filed. + +## Commits + +In order, on `53bd547`. The verification is GitHub's, read through the REST +API (`GET /repos/mindovermachine-dev/product-cli/pulls/168/commits`). + +| SHA | verified | reason | subject | +|---|---|---|---| +| `3587d29197e194e8ca3279ded1b0c5ba408dc017` | true | valid | docs(decisions): ADR-0013, build-time analyzers and the test stack | +| `303bc8c8caa48d39b30f0371ae296f5bd38870fd` | true | valid | build: the build policy and the package register | +| `83827833e12c372800e2b9c187dab42277c1ee5e` | true | valid | ci(gates): no-toolchain allows exactly one toolchain, .NET | +| `748673f20b7ec2350aa78371d6f769a5613f2336` | true | valid | feat: the seven layer libraries, with their API baselines | +| `191b7904d01f0ad679ee709b9284a06abe7e47e7` | true | valid | feat(host): product --version, and exit 2 for every verb not yet built | +| `fb989e3af9c999ace2e470d0764cd553627d6002` | true | valid | test: the layering probes, one per layer | +| `af27ad42f0ef624fca60867b324bdf3eeef45e24` | true | valid | test(canonical): the Varve admission test, with the vendored example | +| `b0048205d0d88bd1a520aea66521ba7ecf059e71` | true | valid | test(host): the version bytes, the verb messages, the argument property | +| `ceb7226625dba590fa143e7793e967382b290906` | true | valid | test(build): the build policy's planted failures, built on every run | +| `0d2836d83a11c329cda896b6dbbc9bddac6dc854` | true | valid | ci(gates): build-test and the AOT publish and smoke matrix | +| `0bbb9ff7a880d3daa1c8d967b1aabec0039b3baf` | true | valid | docs: AGENTS.md and README for the build | + +Two commits follow: the release descriptor, and this record. Their +verification is in the pull request, since a record cannot name its own SHA. + +## Checks + +**Each commit on its own, before the push.** Each commit was checked out +into a clean worktree and run through the gates, then, from the first commit +with a solution, `dotnet build ProductCli.slnx -c Release` and +`dotnet test -c Release --no-build`. + +| Commit | commits | adr-shape | adr-text | no-toolchain | selftest | build | tests | +|---|---|---|---|---|---|---|---| +| `3587d29` | ok | ok | ok | ok | ok | no solution | — | +| `303bc8c` | ok | ok | ok | ok | ok | no solution | — | +| `8382783` | ok | ok | ok | ok | ok | no solution | — | +| `748673f` | ok | ok | ok | ok | ok | ok | no tests | +| `191b790` | ok | ok | ok | ok | ok | ok | no tests | +| `fb989e3` | ok | ok | ok | ok | ok | ok | 10 passed | +| `af27ad4` | ok | ok | ok | ok | ok | ok | 12 passed | +| `b004820` | ok | ok | ok | ok | ok | ok | 38 passed | +| `ceb7226` | ok | ok | ok | ok | ok | ok | 43 passed | +| `0d2836d` | ok | ok | ok | ok | ok | ok | 43 passed | +| `0bbb9ff` | ok | ok | ok | ok | ok | ok | 43 passed | + +**`gates.yml` on `0bbb9ff`**, [run 41638171084](https://github.com/mindovermachine-dev/product-cli/actions/runs/41638171084): all eleven jobs succeeded. + +| Job | Result | Output | +|---|---|---| +| `commits` | success | | +| `signatures` | success | | +| `adr-shape` | success | 13 ADRs | +| `adr-text` | success | ADR-0001 to ADR-0012 | +| `no-toolchain` | success | the new rule | +| `selftest` | success | every clean case passes, every plant fails with its finding | +| `build-test` | success | 43 tests, 0 failed | +| `aot (linux-x64)` | success | smoke passed at `0bbb9ff`; `product` is 1,829,032 bytes | +| `aot (linux-arm64)` | success | smoke passed at `0bbb9ff`; `product` is 1,914,896 bytes | +| `aot (osx-arm64)` | success | smoke passed at `0bbb9ff`; `product` is 1,766,640 bytes | +| `aot (win-x64)` | success | smoke passed at `0bbb9ff`; `product.exe` is 1,692,672 bytes | + +**The planted failures**, built by `BuildPolicyTests` on every run. Each is +also shown here built directly (`ProbePlant=`, `-c Release`): + +``` +ProbePlant=no-adr: exit 1 + error PCLI0001: PackageVersion 'ProductCli.PlantedPackage' carries no Adr= naming the ADR that admits it (ADR-0004 D1, as amended by ADR-0013 D4). +ProbePlant=dangling-adr: exit 1 + error PCLI0002: PackageVersion 'ProductCli.PlantedPackage' cites Adr="9999", and docs/decisions/ holds no ADR-9999-*.md (ADR-0013 D4). +ProbePlant=out-of-scope: exit 1 + error PCLI0003: ProductCli.PolicyProbe (src/ProductCli.PolicyProbe/) references 'CsCheck', whose register entry is scoped to tests/ (ADR-0013 D4). +ProbePlant=reflection-json: exit 1 + error IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' … + error IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' … +``` + +**A mutation, run once by hand and reverted:** `ProductCli.Draft` set to +layer 2 failed the solution build itself, with +`DD0001: 'ProductCli.Draft' is at layer 2 and references 'ProductCli.Shapes', which is at layer 2`. + +**The Varve admission test passed.** `Varve.Turtle` and `Varve.Rdf` +`0.1.0-preview.15` reproduce `sha256:10cd90893f7fadb1bdf80064a43795be95e2175684c6d3a93a7736abc7c2156b` +for `acme-shop.ttl`. + +## Deviations from the plan + +- **No plant commits.** The plan had four planted failures as commits on the branch, shown red and removed. The approval replaced them with `BuildPolicyTests`, which builds each plant on every run. The prompt's "show the failing run, then remove the plant" is met by those tests' runs, pasted in the PR. +- **`Scope=` and the probes.** The plan said a scoped package fails "from a project whose path the scope does not cover". The probes live under `tests/fixtures/`, inside `tests/`, so they are checked as if they were at `src//` (`Directory.Build.targets`). That is what lets a probe stand in for a library. +- **Three files beyond the plan's list:** + - `.gitattributes`, for the Windows runner's line endings; + - `tests/Harness/`, the shared process and probe code, linked into each test project; + - one source file per probe, so the compilation sits in the repository. +- **The layer-6 probe references the host.** A probe at layer L references the L+1 project, and for L = 6 that is `ProductCli.Host`. It is refused like the others. +- **The analyzer configuration is named explicitly** (found above). The plan did not foresee it. +- **The publish folder holds only `product` and its symbol file.** The libraries' documentation and pdb files are kept out of it. +- **The register has no `Microsoft.CodeAnalysis.CSharp`**, because the probes use `dotnet build` (question 1). + +## Reconciliation + +None. `main` stayed at `53bd547` throughout. From 4ce90e9c2a28c3a010c95419a8dbb88dbcfba595 Mon Sep 17 00:00:00 2001 From: Emil Klein Date: Fri, 6 Nov 2026 10:15:46 +0100 Subject: [PATCH 14/14] Accept ADR-0013; accepted-by on v0.8.0-preview.2 Refs #167 Signed-off-by: Emil Klein --- docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md | 2 +- releases/v0.8.0-preview.2.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md b/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md index 800cf7b2..3072062c 100644 --- a/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md +++ b/docs/decisions/ADR-0013-build-time-analyzers-and-test-stack.md @@ -1,6 +1,6 @@ # ADR-0013: Build-time analyzers and the test stack -**Status:** Proposed +**Status:** Accepted (2026-11-03) **Date:** 2026-11-03 **Definition:** chapter 7, Constraints; D1.25 **Amends:** ADR-0004 D1 (its wording only, D4 below) diff --git a/releases/v0.8.0-preview.2.yaml b/releases/v0.8.0-preview.2.yaml index 983de9a8..ee81123e 100644 --- a/releases/v0.8.0-preview.2.yaml +++ b/releases/v0.8.0-preview.2.yaml @@ -12,6 +12,7 @@ basis: - adr: "0003" - adr: "0004" - adr: "0013" +accepted-by: mailto:emil@okkels-klein.dk summary: | ### The repository skeleton (#167)