diff --git a/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md index 0933fb6..728a0a6 100644 --- a/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md +++ b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md @@ -10,11 +10,49 @@ metadata: # Keeping settings, docs and skills in step -> **Repository-local.** This skill lives in `.agents/skills/`, which is gitignored, not packed and not -> published. It is deliberately **not** linked from `AGENTS.md`: `RepositorySkillsSelfTests` fails any -> AGENTS.md link that points into `.agents/skills/`, because a fresh clone does not have this folder. +> **Repository-local.** This skill lives in `.agents/skills/` and is tracked by name (the rest of that +> folder is gitignored); it is not packed and not published. It is deliberately **not** linked from +> `AGENTS.md`: `RepositorySkillsSelfTests` fails any AGENTS.md link that points into `.agents/skills/`. > The published skills are authored under `skills/` and are a different thing entirely. +## The general sweep is a published skill + +This skill is the **settings** sweep for this repository. Any other documentation change — a renamed +recipe, a new wiki page, a moved page, a skill whose scope grew, an audit of whether the docs are +still true — follows the published +[`purview-buildsdk-docs-sync`](../../../skills/purview-buildsdk-docs-sync/SKILL.md) skill: every +claim checked against the code, and the wiki kept hierarchical. In this repository the wiki's root +pages are exactly `Getting-Started.md` (the site's `docs.rootPage`), `Configuration-Reference.md` and +`Repository-Bootstrap.md` (the site catalogue's `docsPage` deep links); every other page lives in one +topic folder, and `DocumentationWikiTests` holds the folders, `_Sidebar.md` and `Home.md` to that. + +## Start from the schema + +`src/src/BuildSdk/Sdk/Sdk.props.buildschema.json` is the canonical description of every SDK property — +description, type and default — and `PropertySchemaTests` holds the code, the `CompilerVisibleProperty` +list, `README.md` and `docs/wiki/Configuration-Reference.md` to it. The generic method (where a +sidecar goes, when a property is pre-import, when to export it, how to rename or retire one) is the +published [`purview-buildsdk-msbuild-properties`](../../../skills/purview-buildsdk-msbuild-properties/SKILL.md) +skill. In this repository, adding a property means: + +1. `Sdk/Props/Defaults.props` — the default, guarded with `'$(X)' == ''`. +2. `Sdk/Sdk.props.buildschema.json` — the entry. `defaultValue` is what a plain class library evaluates + to; without one, end the description with `No default: .`; when `Sdk.props` (or a file it + imports) reads the property, say `Set it before the \`Sdk.props\` import.` The test computes both + from the XML and fails if the description disagrees. +3. `Sdk/Sdk.props` — a `CompilerVisibleProperty` with a ``, only if an analyzer reads it. +4. `README.md` and `docs/wiki/Configuration-Reference.md` — a row in the table for its area (the + Default cell opens with the backticked default, or italics when there is none), plus a row in each + *Compiler-visible SDK properties* table when exported. +5. `CHANGELOG.md` — under the `package.json` version's heading (see the Changelog row below). +6. `just properties-check` — every failure names the file and the row to add. + +A property that must not appear in IntelliSense goes in +`src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyClassification.cs` with a reason: +`ExternallyOwned` (MSBuild/.NET/NuGet), `Internal` (unprefixed implementation detail), +`ComputedDefaults` (machine- or project-dependent value). Prefer a `_` prefix for anything new and +internal — then no list is needed. + ## The rule this exists for A property is written down in several places, and only some of them are checked by a test. The failure @@ -44,11 +82,12 @@ Find the row and make sure every file agrees. | -- | -- | -- | | Runtime default (all projects) | `src/src/BuildSdk/Sdk/Props/Defaults.props` | The default when unset, plus the explanatory comment. Sets the value in `Sdk.props`, which is also what makes it compiler-visible. | | Per-TFM default | `src/src/BuildSdk/Sdk/Targets/Modern.targets` | The `net11.0`+ feature selection. Must agree with `Defaults.props`. | +| Property schema | `src/src/BuildSdk/Sdk/Sdk.props.buildschema.json` | Description, type and `defaultValue` — what csproj IntelliSense shows. `PropertySchemaTests` fails when it disagrees with the code or the docs. | | Compiler-visible description | `src/src/BuildSdk/Sdk/Sdk.props` | The `CompilerVisibleProperty` ``, which is what an analyzer author reads. | | Pinning test | `src/tests/BuildSdk.IntegrationTests/Dotnet11FeaturesTests.cs` | The test that fails when the default changes silently. | -| Changelog | `CHANGELOG.md` | A `[Unreleased]` entry: `### Changed` for behaviour, `### Documentation` for docs-only. | +| Changelog | `CHANGELOG.md` | An entry under `## [] - `: `### Changed` for behaviour, `### Documentation` for docs-only. Never `[Unreleased]` during active development — a merge to `main` publishes the version, so the entry would read as unreleased until it was already live. `[Unreleased]` is only for a pre-release version (`-alpha`, `-beta`, `-rc`). | | README | `README.md` | The feature table row, the property table(s), the quick start, and the `.NET 11 features` section + tiers paragraph. | -| Wiki | `docs/wiki/Configuration-Reference.md`, `Getting-Started.md`, `Version-Detection.md`, `Project-Type-Detection.md` | The property tables, the required-settings and target-framework sections, and the compiler-visible section. | +| Wiki | `docs/wiki/Configuration-Reference.md`, `Getting-Started.md`, `configuration/Version-Detection.md`, `concepts/Project-Type-Detection.md` | The property tables, the required-settings and target-framework sections, and the compiler-visible section. | | Shipped skills | `skills/purview-buildsdk-*/SKILL.md` and `references/*.md` | The property tables and the sections below. | | AGENTS.md | `AGENTS.md` | The "authored set, and what each one answers" table, when a skill's scope grows. | @@ -62,8 +101,9 @@ Two traps worth naming: which it is, in the docs, next to the property. A **feature** bigger than a single property gets its own guide as well. SourceLink is the worked -example, because the decision is per repository host: add a wiki page `docs/wiki/.md` and -register it in `_Sidebar.md` **and** `Home.md`, add a `## ` section to `README.md`, and — when +example, because the decision is per repository host: add a wiki page in its topic folder +(`docs/wiki/packaging/SourceLink.md`) and register it in `_Sidebar.md` **and** that section's table in +`Home.md` — `DocumentationWikiTests` fails until both list it — add a `## ` section to `README.md`, and — when it is configuration — a `references/.md` under `skills/purview-buildsdk-configuration/` that the `SKILL.md` links. Fix any wrong package or property name you find while documenting: the SourceLink pages used to name `Microsoft.SourceLink.AzureDevOps.Git`, @@ -83,7 +123,7 @@ which does not exist. 6. `docs/wiki/Configuration-Reference.md` — the General row **and** the `.NET 11 features` table. 7. Skills — the `MemorySafetyRules`/`PurviewRuntimeAsync` rows in `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`. -8. `CHANGELOG.md` — a `[Unreleased]` `### Changed` entry that names the old default, the new one, how to +8. `CHANGELOG.md` — a `### Changed` entry under the `package.json` version's heading that names the old default, the new one, how to opt in, and what is unchanged. Keep `AllowUnsafeBlocks` (and anything else) at its compiler default: a feature flip must not quietly @@ -110,8 +150,8 @@ Every skill and doc that talks about configuring a project must explain how to s framework(s), consistently: - the default set is `net10.0;net11.0` (`Current`); -- an explicit `TargetFramework`/`TargetFrameworks` must be declared **unconditionally** (the SDK reads - it from the project XML, so a `Condition` is not seen — `PurviewConditionedProjectDeclaration`); +- an explicit `TargetFramework`/`TargetFrameworks` always wins over every set, wherever it is set + except `Directory.Build.targets` (`Targets/TargetFrameworkOwnership.targets` reconciles it); - a curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`) or a redefined `PurviewTargetFrameworks` (pre-import, in `Directory.Build.props`); - a Roslyn component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). @@ -126,7 +166,7 @@ The `CompilerVisibleProperty` → `build_property.*` mechanism, plus a one-line - `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`, - `skills/purview-buildsdk-project-detection/SKILL.md` (the analyzers section), -- `README.md`, `docs/wiki/Configuration-Reference.md` and `docs/wiki/Project-Type-Detection.md`. +- `README.md`, `docs/wiki/Configuration-Reference.md` and `docs/wiki/concepts/Project-Type-Detection.md`. Say both halves: **consume** the exported property instead of re-deriving SDK behaviour, and **add a new one** by listing it as a `CompilerVisibleProperty` item (with a ``) in `Sdk.props` — @@ -145,7 +185,7 @@ skills tooling does not. | `purview-buildsdk-project-detection` | the target-framework subsection, the `build_property.*` note | | `purview-buildsdk-repository-review` | the read list (`global.json`, `package.json`), the target-framework finding | | `purview-buildsdk-testing` | the test-project target-framework paragraph | -| `purview-buildsdk-skill-tests`, `-skill-installation`, `-skills-justfile` | **not** project configuration — leave them alone | +| `purview-buildsdk-skill-tests`, `-skill-installation`, `-justfile` | **not** project configuration — leave them alone | Also update each skill's frontmatter `description` so the change makes the skill **fire** on the right question ("setting the target framework(s)", "pinning the SDK version in global.json"). @@ -157,12 +197,13 @@ just build just skills-validate # PRSKL0005, the pack-time check just version-sync-check # the version pins must still match package.json just lint-check # CSharpier over the repo +just properties-check # schema vs props, defaults, compiler-visible list, README and wiki tables # Targeted and fast (MTP: --treenode-filter, never --filter): dotnet test --project src/BuildSdk.slnx --configuration Debug \ --treenode-filter "/*/*/Dotnet11FeaturesTests/*" --ignore-exit-code 8 dotnet test --project src/BuildSdk.slnx --configuration Debug \ - --treenode-filter "/*/*/(TargetFrameworkSetTests)|(CompilerVisiblePropertyTests)|(CoreDefaultsTests)/*" --ignore-exit-code 8 + --treenode-filter "/*/*/(TargetFrameworkSetTests)|(PropertySchemaTests)|(CoreDefaultsTests)/*" --ignore-exit-code 8 dotnet test --project src/BuildSdk.slnx --configuration Debug \ --treenode-filter "/*/*/(RepositorySkillTests)|(RepositorySkillsSelfTests)|(PurviewSkillValidationTests)/*" --ignore-exit-code 8 ``` diff --git a/.build/just/bump-version.ts b/.build/just/bump-version.ts new file mode 100644 index 0000000..8cb38ff --- /dev/null +++ b/.build/just/bump-version.ts @@ -0,0 +1,62 @@ +// Bumps one part of the semantic version in package.json, for `just bump `. +// +// The parts after the one bumped are reset to 0, and a pre-release or build suffix is dropped: +// +// 2.0.1 major -> 3.0.0 minor -> 2.1.0 patch -> 2.0.2 +// 2.1.0-rc.1 patch -> 2.1.1 +// +// Only the "version" value is rewritten, in place, so the file keeps its own indentation, key order +// and line endings - re-serialising the JSON would reformat it. +// +// bun bump-version.ts [package.json] [--dry-run] + +import { readFileSync, writeFileSync } from "node:fs"; + +const parts = ["major", "minor", "patch"] as const; +type Part = (typeof parts)[number]; + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const [part, file = "package.json"] = args.filter((arg) => arg !== "--dry-run"); + +if (!parts.includes(part as Part)) { + console.error(`Expected one of ${parts.join(", ")}, not '${part ?? ""}'.`); + process.exit(1); +} + +const text = readFileSync(file, "utf8"); +const versionPattern = /("version"\s*:\s*")([^"]*)(")/; +const match = versionPattern.exec(text); +if (!match) { + console.error(`No "version" field in ${file}.`); + process.exit(1); +} + +const current = match[2]; +const semver = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(current); +if (!semver) { + console.error(`'${current}' in ${file} is not a major.minor.patch version.`); + process.exit(1); +} + +let [major, minor, patch] = semver.slice(1, 4).map(Number); +switch (part as Part) { + case "major": + [major, minor, patch] = [major + 1, 0, 0]; + break; + case "minor": + [minor, patch] = [minor + 1, 0]; + break; + case "patch": + patch += 1; + break; +} + +const next = `${major}.${minor}.${patch}`; +if (dryRun) { + console.log(` ${file}: ${current} -> ${next} (dry run, nothing written)`); + process.exit(0); +} + +writeFileSync(file, text.replace(versionPattern, `$1${next}$3`)); +console.log(` ${file}: ${current} -> ${next}`); diff --git a/.build/just/dotnet.just b/.build/just/dotnet.just new file mode 100644 index 0000000..9507263 --- /dev/null +++ b/.build/just/dotnet.just @@ -0,0 +1,53 @@ +# .NET recipes: tools, restore, build, clean, test and pack, all against one solution. +# +# Override these in the root Justfile. `solution_file` is the one nearly every repository sets; "." +# lets dotnet find the single solution or project in the repository root. + +solution_file := "." +build_configuration := "Debug" +artifacts_folder := "./artifacts" + +# Every test. A TUnit/MTP tree-node filter: /assembly/namespace/class/test. +test_filter := "/*/*/*/*/" + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[group('Build and Test')] +ensure-tools: + dotnet tool restore + +# Restore NuGet packages for the solution +[group('Build and Test')] +restore *args: + echo "==> Restoring {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet restore "{{ solution_file }}" {{ args }} + +# Build the solution with build_configuration (default: Debug) +[group('Build and Test')] +build *args: + echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Clean the solution with build_configuration (default: Debug) +[group('Build and Test')] +clean *args: + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Run the tests matching a tree-node filter (default: every test) +# +# just test "/*/*/*/*[Category=Unit]" +# just test "/*/*/ProjectHarnessTests/*" +# +# Exit code 8 - "no test ran" - is ignored, so a filter that matches nothing in one test project does +# not fail the run for the others. +[doc('Run the tests matching a tree-node filter (default: every test)')] +[group('Build and Test')] +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} + +# Pack every packable project into artifacts_folder (default: ./artifacts) +[group('Build and Test')] +pack artifact_folder=artifacts_folder *args: + echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" + dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} diff --git a/.build/just/env.just b/.build/just/env.just new file mode 100644 index 0000000..6156f34 --- /dev/null +++ b/.build/just/env.just @@ -0,0 +1,56 @@ +# The environment every recipe runs in: telemetry opt-outs, and the .NET CLI speed-ups. +# +# `export` hands each value to every process a recipe starts - dotnet, bun, pwsh, git. Each one is a +# default: `env(NAME, value)` keeps whatever the caller's environment already says, so a developer +# who has opted back in, or a CI job that sets its own value, is never overridden. + +# -- Telemetry and tracking -------------------------------------------------------------------------- + +# The .NET CLI, MSBuild and Aspire. +export DOTNET_CLI_TELEMETRY_OPTOUT := env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") +export MSBUILD_TELEMETRY_OPTOUT := env("MSBUILD_TELEMETRY_OPTOUT", "1") +export ASPIRE_CLI_TELEMETRY_OPTOUT := env("ASPIRE_CLI_TELEMETRY_OPTOUT", "true") +export ASPIRE_DASHBOARD_TELEMETRY_OPTOUT := env("ASPIRE_DASHBOARD_TELEMETRY_OPTOUT", "true") + +# The Azure SDKs and the Azure MCP server. +export AZURE_CORE_COLLECT_TELEMETRY := env("AZURE_CORE_COLLECT_TELEMETRY", "0") +export AZURE_MCP_COLLECT_TELEMETRY := env("AZURE_MCP_COLLECT_TELEMETRY", "0") + +# PowerShell, for scripts a recipe runs through pwsh, and Git Credential Manager. +export POWERSHELL_TELEMETRY_OPTOUT := env("POWERSHELL_TELEMETRY_OPTOUT", "1") +export GCM_TELEMETRY_OPTOUT := env("GCM_TELEMETRY_OPTOUT", "1") + +# JavaScript tooling: Next.js, Astro, Yarn, and the funding banners printed by postinstall scripts. +export NEXT_TELEMETRY_DISABLED := env("NEXT_TELEMETRY_DISABLED", "1") +export ASTRO_TELEMETRY_DISABLED := env("ASTRO_TELEMETRY_DISABLED", "1") +export YARN_ENABLE_TELEMETRY := env("YARN_ENABLE_TELEMETRY", "0") +export DISABLE_OPENCOLLECTIVE := env("DISABLE_OPENCOLLECTIVE", "true") + +# Claude Code, when a recipe launches it. +export CLAUDE_CLI_TELEMETRY_OPTOUT := env("CLAUDE_CLI_TELEMETRY_OPTOUT", "1") +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC := env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") +export DISABLE_ERROR_REPORTING := env("DISABLE_ERROR_REPORTING", "1") +export DISABLE_TELEMETRY := env("DISABLE_TELEMETRY", "1") + +# The cross-tool conventions, honoured by Bun and many other CLIs. +export DO_NOT_TRACK := env("DO_NOT_TRACK", "1") +export TELEMETRY_DISABLED := env("TELEMETRY_DISABLED", "1") + +# -- .NET CLI speed and noise ------------------------------------------------------------------------ + +# No welcome banner or first-run notice. +export DOTNET_NOLOGO := env("DOTNET_NOLOGO", "1") + +# The NativeAOT CLI host (.NET 11): near-instant `dotnet --version`, `--help` and tool launches, with +# everything else falling back to the managed CLI. On by default; opt out with DOTNET_CLI_ENABLEAOT=false. +export DOTNET_CLI_ENABLEAOT := env("DOTNET_CLI_ENABLEAOT", "true") + +# The MSBuild server (DOTNET_CLI_USE_MSBUILD_SERVER) is deliberately left to the SDK's own default. +# Forcing it on makes every `dotnet build` share one long-lived MSBuild process, and its NuGet SDK +# resolver keeps the settings it first read: a build under a different nuget.config - a test +# harness's throwaway consumer, a second repository - then resolves SDKs with the wrong sources and +# fallback folders, and fails with MSB4242. Set it yourself where every build shares one NuGet +# configuration; `just scrub` shuts the server down. + +# Multithreaded MSBuild (-mt) is deliberately not set here. Purview.BuildSdk bootstraps it into the +# repository's Directory.Build.rsp, so a plain `dotnet build` and an IDE build get it as well. diff --git a/.build/just/index.just b/.build/just/index.just new file mode 100644 index 0000000..1d73bbe --- /dev/null +++ b/.build/just/index.just @@ -0,0 +1,41 @@ +# The barrel for the shared just modules. The root Justfile imports this file, and nothing else: +# +# set allow-duplicate-variables +# set allow-duplicate-recipes +# +# import ".build/just/index.just" +# +# Every module gives its variables a default. The root Justfile overrides one by defining the same +# name - `solution_file := "src/Acme.slnx"` - and overrides a no-op hook such as `_after-bump` the +# same way. Both settings are what let a root definition win; without them a duplicate is an error. +# +# The required modules are imported unconditionally. The optional ones use `import?`, so a module is +# switched on by copying its file into this folder and off by deleting it: this file never changes. +# +# These files are copied verbatim from the purview-buildsdk-justfile skill. Change a recipe there, +# not here, and put anything specific to one repository in its root Justfile. + +# Telemetry opt-outs and the .NET CLI speed-ups, exported to every recipe. +import "env.just" + +# version, bump +import "version.just" + +# ensure-tools, restore, build, clean, test, pack +import "dotnet.just" + +# lint-check, lint-fix, vs, scrub +import "utilities.just" + +# pipeline-pr, pipeline-build, pipeline-tests - local runs of the Purview.Build pipeline +import "purview-build.just" + +# skills-* - the Purview.BuildSdk agent-skill targets +import "skills.just" + +# Optional. pipeline-pack-validate, pipeline-local-release, pipeline-release - for a repository that +# packs NuGet packages. +import? "purview-build-nuget.just" + +# Optional. docs, docs-install, docs-check - for a pm-docs-site project site (purview-dev/pm-skills). +import? "docs.just" diff --git a/.build/just/purview-build-nuget.just b/.build/just/purview-build-nuget.just new file mode 100644 index 0000000..2154876 --- /dev/null +++ b/.build/just/purview-build-nuget.just @@ -0,0 +1,30 @@ +# Optional: Purview.Build pipeline runs that pack, validate and publish NuGet packages. Copy this file +# into the module folder only when the repository packs packages; the barrel imports it if present. +# +# Pack validation reads PackValidation in .config/purview-build.json. Its RequiredContent lists every +# file each package must contain, and a packed file it does not list fails the run - so a new file +# under a package's Sdk/, build/ or buildTransitive/ needs a RequiredContent entry too. + +# Run the pipeline through pack and pack validation (restore, build, lint, tests, pack, validate), publishing nothing +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish) +# +# `just` runs recipes through sh, which strips backslashes from unquoted arguments. Set the +# LOCAL_NUGET_FEED_PATH environment variable, or use forward slashes: +# +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[doc('Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish)')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the release pipeline, publishing to NuGet (restore, build, lint, tests, pack, validate, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: ensure-tools + echo "==> Running the release pipeline" + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} diff --git a/.build/just/purview-build.just b/.build/just/purview-build.just new file mode 100644 index 0000000..244e44c --- /dev/null +++ b/.build/just/purview-build.just @@ -0,0 +1,24 @@ +# Local runs of the Purview.Build pipeline - the same `dotnet purview-build` tool, and the same +# .config/purview-build.json, that CI runs. Every Build stage - tests, lint, pack and pack validation - +# runs unless that file or an argument turns it off. Anything after the recipe name is passed to the +# tool, so a setting can be overridden for one run: `just pipeline-pr --Build:TestFilter="/*/*/*/*[Category=Unit]"`. + +pipeline_tool := "dotnet purview-build" + +# Run the PR pipeline (restore, build, lint, tests, pack, validate), as configured +[group('Pipeline')] +pipeline-pr *args: ensure-tools + echo "==> Running the PR pipeline" + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint, pack, validate), without tests +[group('Pipeline')] +pipeline-build *args: ensure-tools + echo "==> Running the build pipeline" + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the pipeline with tests forced on and release off (restore, build, lint, tests, pack, validate) +[group('Pipeline')] +pipeline-tests *args: ensure-tools + echo "==> Running the tests pipeline" + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} diff --git a/.build/just/skills.just b/.build/just/skills.just new file mode 100644 index 0000000..0d33902 --- /dev/null +++ b/.build/just/skills.just @@ -0,0 +1,52 @@ +# The Purview.BuildSdk agent-skill targets. Each recipe names one target; the behaviour lives there. +# +# The targets are a thin wrapper over the standard skills CLI (`bun x skills` by default), run from the +# repository root. Every skill target runs once for the whole solution, however many projects it holds, +# so the recipes name the solution, not a hand-picked project. +# +# Every recipe here is run by a person, on purpose. Never make one a dependency of build, test or +# default, or call skills-add, -install, -update or -fix from a git hook: they change the repository, +# and the SDK refuses to run them inside a build (PRSKL0009). skills-check is read-only. +# +# The runner and agents are MSBuild properties (PurviewSkillsRunner, PurviewSkillsAgents), set once in +# Directory.Build.props. Pass a one-off through *args; `dotnet msbuild -p:` splits on `,` and `;`, so +# escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +skills_project := solution_file + +# Install skills from another repository through the skills CLI (append '#' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#' to pin it)")] +[group('Agent Skills')] +skills-add source *args: + echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }}" + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} -v:n {{ args }} + +# Restore every skill skills-lock.json records - how a fresh clone gets the repository's skills back +[group('Agent Skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Update the agent skills by re-adding every unique source in skills-lock.json +# +# Each source is re-added at its latest with every skill it has, which refreshes the installed skills +# and adds any a source has gained; `skills update` is never run. FixPurviewSkills then removes links +# from agent directories PurviewSkillsAgents does not select, and does nothing when it is unset. +# The -t: value is quoted because sh would otherwise end the command at the `;`. +# +# just skills-update -p:PurviewSkillsDryRun=true +[doc('Update the agent skills by re-adding every unique source in skills-lock.json')] +[group('Agent Skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent Skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent Skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/.build/just/utilities.just b/.build/just/utilities.just new file mode 100644 index 0000000..d813497 --- /dev/null +++ b/.build/just/utilities.just @@ -0,0 +1,44 @@ +# Formatting, opening the solution, and scrubbing the build output. + +csharpier := "dotnet csharpier" + +# Check formatting with CSharpier +[group('Utilities')] +lint-check: + {{ csharpier }} check . + +# Format the code with CSharpier +[group('Utilities')] +lint-fix: + {{ csharpier }} format . + +# Open the solution in Visual Studio, or whichever application is registered for it +[group('Utilities')] +[windows] +vs: + start "" "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[macos] +vs: + open "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[linux] +vs: + xdg-open "{{ solution_file }}" + +# Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch +# +# The build servers go first: the MSBuild server and the compiler server hold files open under obj/, +# which on Windows stops them being deleted. node_modules/ and .git/ are never searched. +[doc('Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch')] +[group('Utilities')] +scrub: + echo "==> Scrubbing build output and stopping the build servers" + dotnet build-server shutdown + find . \( -name node_modules -o -name .git \) -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + rm -rf "{{ artifacts_folder }}" + just restore --force-evaluate diff --git a/.build/just/version.just b/.build/just/version.just new file mode 100644 index 0000000..09dbb37 --- /dev/null +++ b/.build/just/version.just @@ -0,0 +1,28 @@ +# The package version, from package.json: the single source of truth Purview.BuildSdk versions every +# project and package from. + +version_file := "package.json" + +# Read by just itself rather than by spawning bun, so `just --list` needs no Node tooling. +current_version := if path_exists(version_file) == "true" { replace_regex(read(version_file), '(?s).*?"version"\s*:\s*"([^"]*)".*', '$1') } else { "0.0.0" } + +# Display the current version, from package.json +[group('Version')] +version: + echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (from {{ version_file }}, stamped into every build by Purview.BuildSdk)" + +# Bump one part of the version in package.json - major, minor or patch - resetting the parts after it +# +# just bump minor # 2.0.1 -> 2.1.0 +# just bump patch --dry-run # print the new version, write nothing +# +# A pre-release suffix is dropped. Requires Bun. Afterwards it runs `_after-bump`, which does nothing +# unless the root Justfile overrides it to refresh whatever else pins the version. +[doc('Bump one part of the version in package.json - major, minor or patch - resetting the parts after it')] +[arg("part", pattern="major|minor|patch")] +[group('Version')] +bump part *args: && _after-bump + bun "{{ source_directory() / 'bump-version.ts' }}" "{{ part }}" "{{ version_file }}" {{ args }} + +# Runs after a successful `bump`. Override it in the root Justfile, e.g. `_after-bump: version-sync`. +_after-bump: diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f043030..1978504 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.8.1", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.config/lefthook.yml b/.config/lefthook.yml index cd475bf..1a4201c 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -1,11 +1,4 @@ -pre-commit: - jobs: - - name: csharpier check - run: just lint-check - - name: version pins - run: just version-sync-check - commit-msg: jobs: - name: conventional commits - run: bunx commitlint --edit {1} + run: bun x --bun commitlint --edit {1} diff --git a/.config/purview-build.json b/.config/purview-build.json index 97b2f99..469ad4e 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -21,10 +21,13 @@ "Sdk/Props/PackedVersion.props", "Sdk/Props/VersionDetection.props", "Sdk/Sdk.props", + "Sdk/Sdk.props.buildschema.json", "Sdk/Sdk.targets", "Sdk/Targets/Blazor.targets", "Sdk/Targets/Modern.targets", + "Sdk/Targets/PropertySchema.targets", "Sdk/Targets/ServerShared.targets", + "Sdk/Targets/TargetFrameworkOwnership.targets", "Sdk/Targets/VersionDetection.targets" ], "purview.skills.testing": [ diff --git a/.editorconfig b/.editorconfig index 229c475..a1e0051 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 2.0.1 - managed file, do not edit +# Purview.BuildSdk 2.1.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.gitattributes b/.gitattributes index 8d48b3e..e2f6fb7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -49,6 +49,10 @@ *.jsx text *.ts text *.tsx text +*.mjs text +*.mts text +*.cjs text +*.cts text *.html text diff=html *.css text *.scss text @@ -68,8 +72,16 @@ Dockerfile text eol=lf *.dockerfile text eol=lf justfile text eol=lf +Justfile text eol=lf +*.just text eol=lf Makefile text eol=lf +# Lockfiles -- text (bun.lock is JSONC) but generated, so collapse them in diffs/PRs. +bun.lock text eol=lf -diff linguist-generated +package-lock.json text eol=lf -diff linguist-generated +pnpm-lock.yaml text eol=lf -diff linguist-generated +yarn.lock text eol=lf -diff linguist-generated + # Patches carry their own endings -- never touch them. *.patch -text diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6aac13e..39a9273 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,5 +15,5 @@ Keep product, architecture, and general engineering standards centralized in `AG - Apply the `AGENTS.md` testing bar strictly (TUnit/TUnit.Mocks, AAA comments, naming, cancellation token rule). - Treat work as incomplete until relevant tests pass. -- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution quality. +- Consult the skills `AGENTS.md` lists under `skills/`, and the repository-local skill under `.agents/skills/`, when they match the task. - Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/AGENTS.md b/AGENTS.md index b4e5f52..9d9672e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,9 @@ Everything the CLI writes is gitignored, because it is all reproducible from the One entry under `.agents/skills/` is **tracked**: `purview-buildsdk-settings-docs-sync`, a repository-local skill. It is guidance about working *in this repository*, so it must not ship to a consumer and cannot live under `skills/`, and `.agents/skills/` is the one location an agent reads natively here. `.gitignore` un-ignores it by name; `RepositorySkillsSelfTests.InstalledSkillsAreNotTracked` still fails if any skill the CLI *installed* — anything recorded in `skills-lock.json` — is tracked. -**Skills have exactly one installer, and it is the skills CLI.** The SDK writes nothing into an agent directory at all — the `.agents` mirror that used to copy a package's `agents/` and `prompts/` has been removed outright. A package still ships `.agents/skills//`, but as *metadata*: it is what `ResolvePurviewSkillSources` reads to map a package to its skills and commit, not content to copy. +**Skills have exactly one installer, and it is the skills CLI.** The SDK writes nothing into an agent directory at all — the `.agents` mirror that used to copy a package's `agents/` and `prompts/` has been removed outright. The SDK packs no skills into a package and reads none from one: skills live in a repository's root `skills/` and are installed from git by the standard CLI. -The single-writer rule is why. The CLI's universal install location is that same folder, so a second writer would mean the mirror silently overwriting a pinned install on the next build while `PRSKL0011` compared a lockfile ref against content the mirror had already replaced. See "The agent-folder mirror was removed" below for what went with it. +The single-writer rule is why. The CLI's universal install location is that same folder, so a second writer would mean the mirror silently overwriting a pinned install on the next build. See "The agent-folder mirror was removed" below for what went with it. Link to a published skill by its **authored** path (`./skills//SKILL.md`). A link into `.agents/skills/` points at a CLI-installed, gitignored file and is broken in a fresh clone; `RepositorySkillsSelfTests` fails the build if one reappears. The repository-local skill needs no link here — the agent runtime finds it. @@ -60,7 +60,7 @@ Link to a published skill by its **authored** path (`./skills//SKILL.md`). This repo builds and tests `Purview.BuildSdk`, a reusable MSBuild SDK package plus analyzer and tests. -For full product behaviour and configuration, read [`README.md`](./README.md). Keep edits minimal, targeted, and convention-driven. +For full product behaviour and configuration, read [`README.md`](README.md). Keep edits minimal, targeted, and convention-driven. **Prefer a skill to a prompt, and a skill to a shipped sub-agent.** This SDK used to ship two prompts and two sub-agent definitions under `Sdk/.agents/`. All four are gone: `sdk-review-repository-shape` and `sdk-repository-rationaliser` became `purview-buildsdk-repository-review`, `sdk-consumer-setup` became `purview-buildsdk-adoption`, and `sdk-diagnose-agent-folder-copy` described a feature that no longer exists. A prompt or a mirrored agent file is inert — it lands somewhere no agent reads natively, so somebody has to know it exists. A skill carries a `description` that an agent matches against the task, so it fires when it is relevant. Anything task-shaped and reusable belongs in `skills/`. @@ -72,15 +72,17 @@ The authored set, and what each one answers: | Skill | Answers | | -- | -- | -| [`purview-buildsdk-adoption`](./skills/purview-buildsdk-adoption/SKILL.md) | wiring the SDK into a repository, required settings and versions, and why a property had no effect | -| [`purview-buildsdk-configuration`](./skills/purview-buildsdk-configuration/SKILL.md) | which property controls this, where must it be set, how to set the target framework(s), and which SourceLink provider matches the repository host | -| [`purview-buildsdk-project-layout`](./skills/purview-buildsdk-project-layout/SKILL.md) | where does a project go, what is it called, and which target framework(s) it uses | -| [`purview-buildsdk-project-detection`](./skills/purview-buildsdk-project-detection/SKILL.md) | why did the SDK do that automatically, target framework included | -| [`purview-buildsdk-testing`](./skills/purview-buildsdk-testing/SKILL.md) | writing TUnit tests, and running them under MTP | -| [`purview-buildsdk-skill-tests`](./skills/purview-buildsdk-skill-tests/SKILL.md) | authoring a skill, and validating it with `Purview.Skills.Testing` | -| [`purview-buildsdk-skill-installation`](./skills/purview-buildsdk-skill-installation/SKILL.md) | installing the skills a package ships, and the `PRSKL*` diagnostics | -| [`purview-buildsdk-repository-review`](./skills/purview-buildsdk-repository-review/SKILL.md) | auditing a repository against the SDK's conventions | -| [`purview-buildsdk-skills-justfile`](./skills/purview-buildsdk-skills-justfile/SKILL.md) | adding or updating `just` recipes that run the skill targets | +| [`purview-buildsdk-adoption`](skills/purview-buildsdk-adoption/SKILL.md) | wiring the SDK into a repository, required settings and versions, and why a property had no effect | +| [`purview-buildsdk-configuration`](skills/purview-buildsdk-configuration/SKILL.md) | which property controls this, where must it be set, how to set the target framework(s), and which SourceLink provider matches the repository host | +| [`purview-buildsdk-project-layout`](skills/purview-buildsdk-project-layout/SKILL.md) | where does a project go, what is it called, and which target framework(s) it uses | +| [`purview-buildsdk-project-detection`](skills/purview-buildsdk-project-detection/SKILL.md) | why did the SDK do that automatically, target framework included | +| [`purview-buildsdk-testing`](skills/purview-buildsdk-testing/SKILL.md) | writing TUnit tests, and running them under MTP | +| [`purview-buildsdk-skill-tests`](skills/purview-buildsdk-skill-tests/SKILL.md) | authoring a skill, and validating it with `Purview.Skills.Testing` | +| [`purview-buildsdk-skill-installation`](skills/purview-buildsdk-skill-installation/SKILL.md) | installing the skills a package ships, and the `PRSKL*` diagnostics | +| [`purview-buildsdk-repository-review`](skills/purview-buildsdk-repository-review/SKILL.md) | auditing a repository against the SDK's conventions | +| [`purview-buildsdk-justfile`](skills/purview-buildsdk-justfile/SKILL.md) | the root `Justfile` and the shared `.build/just/` modules: .NET, version and `bump`, pipeline, skill targets, utilities, docs site, and the exported environment | +| [`purview-buildsdk-msbuild-properties`](skills/purview-buildsdk-msbuild-properties/SKILL.md) | adding, renaming, deprecating or removing an MSBuild property, its `.buildschema.json` sidecar, and `ValidatePurviewPropertySchema` (`PRPSC*`) | +| [`purview-buildsdk-docs-sync`](./skills/purview-buildsdk-docs-sync/SKILL.md) | keeping the skills, agent instructions, READMEs and `docs/wiki/` true to the code, and the wiki hierarchical | After editing one, run `just skills-validate` for the pack-time check and `just test` for the full rule set. @@ -105,9 +107,7 @@ The `BuildSdk.csproj` itself is an MSBuild SDK, so it disables `PurviewAutoSdkPa The SDK used to mirror a package's `.agents/**` into the consuming repository before build. That whole feature is gone — `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, `PurviewAgentFolderSourcePath`, the `PurviewAgentFolderCopy*` properties, `PurviewAgentSyncManifestPath`, `DisableMirroredFileTrackingCheck`/`PurviewTrackedMirroredFile`, the `agent-sync.cache` manifest, the generated destination `.gitignore` files, and the `Mirror` mode of `SyncPurviewRepositoryFiles` along with it. -It was removed rather than defaulted off because it duplicated the skills CLI, and duplication here was not neutral: the CLI's universal install location is `.agents/skills/`, so the two were two writers on one set of paths. The mirror would silently overwrite a pinned install on the next build while `PRSKL0011` compared a lockfile ref against content the mirror had already replaced. Once skills are the only thing worth delivering, and the CLI delivers them, the mirror had nothing left to carry. - -One consequence worth knowing: `.agents` used to be **excluded** from the scan that decides whether a lockfile has ever been restored (`EvaluatePurviewSkillState`), precisely because the mirror wrote there and finding it proved nothing. It is now **included** — excluding it would report `PRSKL0014` against a repository whose skills are correctly installed. `PurviewSkillRecommendationTests.AgentsSkillsFolder_CountsAsARestoredInstall` pins that. +It was removed rather than defaulted off because it duplicated the skills CLI, and duplication here was not neutral: the CLI's universal install location is `.agents/skills/`, so the two were two writers on one set of paths. The mirror would silently overwrite a pinned install on the next build. Once skills are the only thing worth delivering, and the CLI delivers them, the mirror had nothing left to carry. `SyncPurviewRepositoryFiles` (inline task in `Sdk/Sdk.targets`) survives, doing only what it still needs to: the `.editorconfig`/`global.json` bootstraps (`PurviewRepoBootstrapMode`: `IfMissing`/`Always`/`WarnOnDrift`/`Never`). Every project in a solution writes those same destinations, so an existing file is never overwritten unless asked — which turns a lost race between parallel projects into a no-op — each write is staged into a temporary file beside the destination and renamed into place, retries are quiet, and only a failure surviving every retry is reported. `PurviewSuppressCopyRetryWarnings` (default `true`) demotes the built-in copy task's `MSB3026` notice to a message. @@ -118,33 +118,30 @@ Tests live in `src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs` ( Installing skills into the agent-specific directories is a separate, **explicit** step — it must never be a side effect of a restore, a build, a design-time build or a test run. -Two targets, neither with `BeforeTargets`/`AfterTargets`: +The skill targets are a **thin wrapper over the standard skills CLI** (`bun x skills` by default), with nothing bespoke: no package-to-skill mapping, no metadata in packages, no competing lockfile. A repository publishes skills in its root `skills/`; the CLI installs them and records each in `skills-lock.json`. ```bash -dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing -dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo # skills add "owner/repo" --skill "*" +dotnet msbuild -t:InstallPurviewSkills # skills experimental_install +dotnet msbuild -t:UpdatePurviewSkills # skills add "" --skill "*", once per lockfile source +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -`ResolvePurviewSkillSources` (inline task in `Sdk/Sdk.targets`) does the resolution. There is deliberately **no custom `skills.json` manifest**: a package that ships skills already carries both halves of the mapping, and the resolver reads them. - -- `.agents/skills//` in the restored package says which skills it ships. -- The nuspec's `` element, written by SourceLink for every package built by this SDK, says which git repository and which exact commit produced them. - -That makes version compatibility exact by construction: the resolved ref is the commit the *restored package version* was built from, so an older package reference gets that version's skills rather than whatever is on the default branch. A package whose nuspec has no GitHub repository URL, or no commit, cannot be pinned — it is reported as `PRSKL0002` rather than installed from a guessed branch. `PRSKL0001` is raised when `project.assets.json` is missing, because resolution reads the restore graph and deliberately does not trigger a restore of its own. - -Installation itself is delegated to the skills CLI (`bun x skills` by default) rather than reimplemented, because the CLI owns the agent-specific destinations and their discovery conventions — which differ per agent and change independently of this SDK — and owns `skills-lock.json`, so a repository can restore the same set later with `skills experimental_install`. +The caller owns the ref: append `#` to a source to pin it. With no source and no lockfile, install is `PRSKL0007`. This used to be package-driven — `ResolvePurviewSkillSources` read a `.agents/skills/` folder inside each restored package plus its nuspec commit, and `ListPurviewSkills`/`RecommendPurviewSkills`/`VerifyPurviewSkills` reported missing or drifted skills (`PRSKL0001`/`0002`/`0010`–`0014`/`0017`). All of that was removed: it was bespoke metadata that no package built by this SDK carried, so it never found anything. -`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` at all, so the CLI auto-detects agents. Set, it becomes one `--agent` per name (`_ResolvePurviewSkillsAgents` splits on `,`, `;` and spaces) — the CLI's `--agent` does not split a comma-separated value, so the old `--agent "claude-code,codex"` form named one agent that does not exist. `dotnet msbuild -p:` itself splits on `,` and `;`, so a command line needs `%2C` or a quoted value; tests use `%2C`. +The CLI owns the agent-specific destinations and their discovery conventions — which differ per agent and change independently of this SDK — and owns `skills-lock.json`, which is why installation is delegated rather than reimplemented. +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` at all, so the CLI auto-detects agents. Set, it becomes one `--agent` per name (`_ResolvePurviewSkillsAgents` splits on `,`, `;` and spaces) — the CLI's `--agent` does not split a comma-separated value, so the old `--agent "claude-code,codex"` form named one agent that does not exist. `dotnet msbuild -p:` itself splits on `,` and `;`, so a command line needs `%2C` or a quoted value; tests use `%2C`. A restore (`experimental_install`) is passed no agent flag. ### Package runners and the repository root Two invariants govern how the CLI is launched, and both are load-bearing. -**Any package runner, defaulting to bun.** The CLI is published to npm and each runner spells "run this npm package" differently. `PurviewSkillsRunner` (default `bun`, i.e. `bun x skills@1`) selects one; the mapping lives in the `PurviewSkillsRunnerLauncher` item group in `Sdk/Sdk.targets`, which carries an `Exec` value (version-pinned, for `InstallPurviewSkills`) and a `Display` value (unpinned, for recommendation text) per runner. A runner with no entry falls through to ` $(PurviewSkillsPackage)`, so an unknown runner still works. Each invocation form was verified against the real tool — `npx -y`, `bunx`, `bun x`, `pnpm dlx`, `yarn dlx` (Yarn 2+ only), `deno run -A npm:` — because a wrong form is a command that simply does not run. +**Any package runner, defaulting to bun.** The CLI is published to npm and each runner spells "run this npm package" differently. `PurviewSkillsRunner` (default `bun`, i.e. `bun x skills@1`) selects one; the mapping lives in the `PurviewSkillsRunnerLauncher` item group in `Sdk/Sdk.targets`, which carries one version-pinned `Exec` launcher per runner. A runner with no entry falls through to ` $(PurviewSkillsPackage)`, so an unknown runner still works. Each invocation form was verified against the real tool — `npx -y`, `bunx`, `bun x`, `pnpm dlx`, `yarn dlx` (Yarn 2+ only), `deno run -A npm:` — because a wrong form is a command that simply does not run. `DetectPurviewSkillsCli` (inline task) probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then `$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. `PATH` is read directly rather than by spawning a version check: deterministic, no process, cannot hang on a cold download. The runner actually found wins over the configured preference. Nothing found is `PRSKL0004`; `PurviewSkillsCommand` overrides the launcher entirely and skips detection. -**Always the repository root.** `skills-lock.json` and the agent directories belong to the repository, not to a project, so `ResolvePurviewSkillsRepositoryRoot` resolves one root and every skill target runs from it: `PurviewSkillsInstallRoot` → a user-set `PurviewSkillsRepoRoot` → the marker walk → `PurviewSkillsRepoRoot`/`RepoRoot` as guessed → CI workspace variables → SourceLink's git `SourceRoot`. There is deliberately **no project-directory fallback** — that would scatter a lockfile and an agent directory through the tree, one per project. An unresolved root is `PRSKL0003` on install (naming `PurviewSkillsInstallRoot`), and silence for recommendations, because a build never fails over advice. +**Always the repository root.** `skills-lock.json` and the agent directories belong to the repository, not to a project, so `ResolvePurviewSkillsRepositoryRoot` resolves one root and every skill target runs from it: `PurviewSkillsInstallRoot` → a user-set `PurviewSkillsRepoRoot` → the marker walk → `PurviewSkillsRepoRoot`/`RepoRoot` as guessed → CI workspace variables → SourceLink's git `SourceRoot`. There is deliberately **no project-directory fallback** — that would scatter a lockfile and an agent directory through the tree, one per project. An unresolved root is `PRSKL0003` (naming `PurviewSkillsInstallRoot`). The marker walk is `FindPurviewRepositoryRoot` (inline task), driven by `PurviewRepositoryRootMarkers` — `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json`. Three things about it are load-bearing and easy to break: @@ -154,13 +151,13 @@ The marker walk is `FindPurviewRepositoryRoot` (inline task), driven by `Purview The marker walk outranks the evaluation-time `PurviewSkillsRepoRoot` guess (which only knows `RepoRoot` and an `AGENTS.md` walk-up), because an `AGENTS.md` can legitimately sit in a subdirectory. `_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` before defaulting so a value the *user* set still wins. -`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and reporting needs no Node tooling at all. +A dry run (`PurviewSkillsDryRun=true`) with no runner on `PATH` prints the configured runner's launcher rather than failing. -Note that a target's `Condition` is evaluated *before* its `DependsOnTargets` run, so a property a dependency computes is still empty there. That is why the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on `RecommendPurviewSkills` — and equally why the build hook's CI/design-time gating works at all. +Note that a target's `Condition` is evaluated *before* its `DependsOnTargets` run, so a property a dependency computes is still empty there. That is why the unresolved-root check (`PRSKL0003`) and the `PRSKL0009` guard are `Error`s inside dependency targets rather than `Condition`s on the public targets. ### Validating skills -`ValidatePurviewSkills` runs `BeforeTargets="Pack"` and checks that every authored skill is well formed, so a malformed skill fails in the repository that owns it rather than reaching the CLI, which would silently skip it. It validates the folder under the root `ResolvePurviewSkillsRepositoryRoot` resolved — the same one install and recommendation use — rather than the evaluation-time `PurviewSkillsSourceFolder`, because that guess only knows `RepoRoot` and an `AGENTS.md` walk-up: on a CI runner `RepoRoot` comes from `GITHUB_WORKSPACE`, so reading it there validated a different repository's skills and passed silently. It checks: +`ValidatePurviewSkills` runs `BeforeTargets="Pack"` and checks that every authored skill is well formed, so a malformed skill fails in the repository that owns it rather than reaching the CLI, which would silently skip it. It validates the folder under the root `ResolvePurviewSkillsRepositoryRoot` resolved — the same one every skill target uses — rather than the evaluation-time `PurviewSkillsSourceFolder`, because that guess only knows `RepoRoot` and an `AGENTS.md` walk-up: on a CI runner `RepoRoot` comes from `GITHUB_WORKSPACE`, so reading it there validated a different repository's skills and passed silently. It checks: - every folder under the skills root has a `SKILL.md`, - that file opens with YAML frontmatter carrying `name` and `description` (a `>-` block scalar counts), @@ -171,6 +168,41 @@ Failures are reported as `PRSKL0005`, one per problem. Every authored skill is c Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillValidationTests.cs`. +## The property schema + +`src/src/BuildSdk/Sdk/Sdk.props.buildschema.json` is the one canonical description of every SDK +property: description, type and default. It is an MSBuild Editor sidecar — the editor reads +`.buildschema.json`, so it must keep that exact name beside `Sdk.props`, and it is packed +to `Sdk/Sdk.props.buildschema.json` by the ordinary `Sdk/**` item (`PackedSdk_PropertySchema_IsPackedBesideSdkProps`). +Any file newly packed under `Sdk/` — a sidecar, a `Targets/*.targets` — also needs an entry in +`.config/purview-build.json` `PackValidation.RequiredContent`, or the pipeline's pack validation fails +with `undeclared content`. + +`PropertySchemaTests` holds everything else to it, from a static read of the shipped XML plus one +evaluated reference project: every public property defined has an entry and every entry is defined or +read; every `CompilerVisibleProperty` has an entry and every `build_property.*` an analyzer reads is +exported; every `defaultValue` equals the evaluated default; a description claims "set it before the +`Sdk.props` import" exactly when `Sdk.props` (or a file it imports) reads the property; the README and +`docs/wiki/Configuration-Reference.md` tables list every entry with the same default; and the file +validates against the meta-schema vendored in `src/tests/BuildSdk.IntegrationTests/Schemas/` (offline, +by `DraftFourValidator`, which fails if the meta-schema uses a keyword it does not implement). + +The deliberate exceptions — properties MSBuild owns, unprefixed internals, machine-dependent defaults — +live in `PropertySchema/SdkPropertyClassification.cs`, each with its reason. Prefer a `_` prefix for a new +internal property over adding to a list. The repository-local `purview-buildsdk-settings-docs-sync` +skill has the step-by-step sweep. + +`Targets/PropertySchema.targets` ships the reusable half of that check as `ValidatePurviewPropertySchema` +(`PRPSC0001`–`PRPSC0004`), opt-in through `EnablePurviewPropertySchemaValidation` for packages that author +their own sidecars. It never runs in an ordinary build. `PropertySchemaValidationTargetTests` runs it +against this SDK's own `Sdk/` folder, so the shipped check and the test suite cannot disagree. + +`Targets/TargetFrameworkOwnership.targets` is imported through the .NET SDK's `BeforeMicrosoftNETSdkTargets` +hook — after the project body, before `IsCrossTargetingBuild` is decided — so an explicit +`TargetFramework`/`TargetFrameworks` set anywhere other than `Directory.Build.targets` wins over a curated +set. It never fires in an inner build (`TargetFramework` global), which `Sdk.props` detects by trying to +overwrite the value. + ## Purview.Skills.Testing `src/src/SkillsTesting/` ships a second package: the Agent Skills conformance rule set. It is shaped for the same reason as every rule engine: the rules are defined once rather than reimplemented in every repository that authors skills. @@ -197,39 +229,26 @@ Two rules are worth knowing the reasoning for. A repository with **no** `skills/ Tests live in `src/tests/SkillsTesting.UnitTests/`, with the repository-level ones in `src/tests/BuildSdk.IntegrationTests/RepositorySkillTests.cs`, `SkillRepositoryRootParityTests.cs` and `RepositorySkillsSelfTests.cs`. -### Checking skills - -**Nothing about skills runs during a build.** This used to be the opposite — a -`RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"` — and it was removed after -measurement: on a 39-project solution it read and regex-scanned every project's -`project.assets.json` (≈500 KB, 256 libraries each), made ~10 000 failing `Directory.Exists` calls, -read the lockfile and walked the repository root, on every build, to print advice that a repository -managing its own skills had already acted on. Every input is repository-scoped, so the per-project -cost was pure duplication. The remaining work is now paid only when somebody asks: +### Nothing about skills runs during a build -```bash -dotnet msbuild -t:RecommendPurviewSkills # report, never fail -dotnet msbuild -t:VerifyPurviewSkills # report, and fail on anything unverifiable -dotnet msbuild -t:InstallPurviewSkills # install every shipped skill at its package's commit -dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills -dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist -dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory -dotnet msbuild -t:ListPurviewSkills # what the restored packages ship -``` +There used to be a `RecommendPurviewSkillsOnBuild` hook on `AfterTargets="Build"`. It was removed after +measurement — on a 39-project solution it read and regex-scanned every project's `project.assets.json` +on every build to print advice — and the recommendation targets went later with the package-to-skill +mapping. What remains is the explicit targets in "Explicit skill installation" above. ### Once per solution: the leader Pointed at a solution, MSBuild runs a custom target in **every** project, so each skill target used -to re-add every source and re-run the CLI once per project. Every public skill target is now a thin -wrapper: `_ResolvePurviewSkillsProjects` reads the solution's project list from +to re-run the CLI once per project. Every public skill target is now a thin wrapper: +`_ResolvePurviewSkillsProjects` reads the solution's project list from `CurrentSolutionConfigurationContents` (the same value in every project, for `.sln` and `.slnx`; -outside a solution it is the project itself), asks each project for its assets file through +outside a solution it is the project itself), asks each project whether it imports the SDK through `_GetPurviewSkillsProjectInfo`, and elects as leader the first, by sorted path, that answered. The wrapper then calls `_Core` on the leader through the `MSBuild` task. MSBuild runs one project's target once per set of global properties and returns the cached result to every later caller, so the work runs once — no lock and no stamp file. -Four things are load-bearing: +Three things are load-bearing: - **The leader is a consumer project, not a `.proj` shipped in the package.** The skill settings live in the consumer's `Directory.Build.props`, which a project in the NuGet cache would never import. @@ -239,63 +258,23 @@ Four things are load-bearing: - **The query's reply has its own item type** (`_PurviewSkillsProjectInfoReply`). Asking the calling project runs the query in the caller's own instance, so reusing the caller's item put the caller first in its own list, and every project elected itself. -- `ResolvePurviewSkillsTarget` resolves across **every** answering project's assets file, so a package - only a test project references is found; packages are de-duplicated by id and version, and one id at - two versions is `PRSKL0017`. The old per-framework condition is gone: it made a multi-targeted - project's outer build resolve nothing. Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillSolutionTests.cs`, which writes its own `.slnx` because the harness's lists projects as `` entries. -### Updating, checking and fixing - -**Install, update and fix run only when named — never automatically.** Two layers, both tested in `PurviewSkillTargetIsolationTests`: no SDK target hooks or depends on them (a structural scan of every `.props`/`.targets` under `Sdk/`, including `*DependsOn` properties), and `_GuardExplicitPurviewSkillsTarget` — first in each one's `DependsOnTargets` — fails with `PRSKL0009` when `BuildingProject`, `DesignTimeBuild` or `MSBuildIsRestoring` is true, which catches a consumer's own `AfterTargets="Build"` hook. Do not add a `BeforeTargets`/`AfterTargets` to any of them, and do not weaken the guard. - -`UpdatePurviewSkills` (`ReadPurviewSkillsLockSources` inline task) re-adds each unique lockfile source once, and **that is the whole update — `skills update` is never run**. `skills add` reinstalls every skill it selects (verified in the CLI source, skills 1.7.6: `installSkillForAgent` cleans each skill's folder and copies it again, skipping only when source and destination are the same folder), so one re-add both refreshes the installed skills and adds any the source has gained; `skills update` only re-fetches entries the lockfile already has, so it would add nothing. A source a restored package ships is re-added at **that package's commit**, never unpinned — re-adding it at latest would turn a satisfied entry into `PRSKL0011` drift — naming every skill that package version ships. Every other source is re-added at latest with `--skill "*"`, never the lockfile's recorded names, which would leave out anything new. `PurviewSkillsUpdateMode` and `PRSKL0006` went with the `skills update` step. The re-adds use target batching (`_UpdatePurviewSkillsSource`) so each failure is attributed to its source (`PRSKL0008`, `ErrorAndContinue` so all are named); `-p:PurviewSkillsDryRun=true` prints and runs nothing. - -`CheckPurviewSkills`/`FixPurviewSkills` (`EnforcePurviewSkillDirectories`) do nothing unless `PurviewSkillsAgents` names agents. Then the only agent skill directories allowed are those agents', per the `PurviewSkillsAgentDirectory` item group (copied from the CLI's agent table — extend it, don't hard-code), plus `.agents/skills`, the canonical store every agent directory links into. The authored `skills/` folder is protected even though openclaw installs there. Fix deletes links only (never their targets — `RemoveDirectory` on Windows, `unlink` on Unix) and folders left empty; a real file is kept and reported as `PRSKL0015`, because it cannot be told from hand-written content. Note the CLI writes copies, not links, for some agent selections, so those are kept too. - -Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillUpdateTests.cs`. - -`VerifyPurviewSkills` is the gate a CI job calls. It escalates `PRSKL0010` (missing) and `PRSKL0011` -(drifted) from warning to **error** — both are factual, both are fixed by one command, and neither can -fire without a referenced package that really ships skills. `PRSKL0012` stays a warning, because a -package published without repository metadata is not the consumer's to fix; `PRSKL0013`/`PRSKL0014` -stay messages, because 0013 is an owner-scoped heuristic and 0014 fires on any clone of a repository -that installs skills on demand. Failing a build over either would hand people errors they cannot -clear, which is how a gate gets switched off. The escalation is safe because the target cannot be -reached by accident: a build that did not ask for it is never broken by skill advice. - -`EvaluatePurviewSkillState` (inline task in `Sdk/Sdk.targets`) joins the skills each restored package ships against the CLI's own `skills-lock.json` and classifies every one: satisfied, missing (`PRSKL0010`), ref-drifted (`PRSKL0011`), unverifiable (`PRSKL0012`), orphaned (`PRSKL0013`), or recorded-but-never-restored (`PRSKL0014`). Both inputs are local, so it stays offline and spawns no process. +### Installing, updating, checking and fixing -The lockfile is read directly rather than by parsing `npx skills list --json`, because that listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs no Node tooling, which is what keeps recommendations working on a machine that can build .NET but has no Node. There is deliberately no competing lockfile. +**Install, update and fix run only when named — never automatically.** Two layers, both tested in `PurviewSkillTargetIsolationTests`: no SDK target hooks or depends on them (a structural scan of every `.props`/`.targets` under `Sdk/`, including `*DependsOn` properties), and `_GuardExplicitPurviewSkillsTarget` — first in each one's `DependsOnTargets` — fails with `PRSKL0009` when `BuildingProject`, `DesignTimeBuild` or `MSBuildIsRestoring` is true, which catches a consumer's own `AfterTargets="Build"` hook. A git hook or a recipe dependency that runs a target on its own is not a build and is not caught. Do not add a `BeforeTargets`/`AfterTargets` to any of them, and do not weaken the guard. `PurviewSkillTargetIsolationTests.OrdinaryBuild_DoesNotInstallSkills` pins that an ordinary restore-and-build writes no lockfile and creates no agent directory. -Drift **never** recommends `npx skills update`: `update` installs the newest published skill, which is only correct when the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in a pinned `npx skills add` instead. +`InstallPurviewSkills` runs `skills add "" --skill "*"` for `PurviewSkillsSource`, or `skills experimental_install` without one (`PRSKL0007` when there is no lockfile either). -Three things shape the output, and all of them are load-bearing: +`UpdatePurviewSkills` (`ReadPurviewSkillsLockSources` inline task) re-adds each unique lockfile source once, at its latest with `--skill "*"`, and **that is the whole update — `skills update` is never run**. `skills add` reinstalls every skill it selects (verified in the CLI source, skills 1.7.6: `installSkillForAgent` cleans each skill's folder and copies it again, skipping only when source and destination are the same folder), so one re-add both refreshes the installed skills and adds any the source has gained; `skills update` only re-fetches entries the lockfile already has. The lockfile's recorded names are deliberately not reused, which would leave out anything new. The re-adds use target batching (`_UpdatePurviewSkillsSource`) so each failure is attributed to its source (`PRSKL0008`, `ErrorAndContinue` so all are named); `-p:PurviewSkillsDryRun=true` prints and runs nothing. `PurviewSkillsUpdateMode` and `PRSKL0006` went with the `skills update` step. -- missing and drifted are warnings (actionable in one command), while unverifiable and orphaned are messages — and `VerifyPurviewSkills` is what turns missing and drifted into failures; -- a correctly installed set produces no output at all. -- there is no deduplication stamp any more (`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` are retired with it). A stamp existed so a per-build hook could report one state once per repository; a target a person invokes should answer every time. - -Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so only an entry under an owner the project references — but from a repository it no longer references — is reported. A skill from any other owner is the developer's own and is never mentioned. - -Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillRecommendationTests.cs`, with the shared on-disk fixtures (fake restored package, assets file, lockfile) in `SkillPackageFixtures.cs`. Runner mapping and repository-root resolution are tested in `PurviewSkillInstallCommandTests.cs`. - -Two harness facts to know when adding tests here. `BuildingProject` is only `true` when the `Build` target actually runs, so tests driving the build hook by name must supply it — `AnOrdinaryBuild_ReportsSkillState` covers the genuine end-to-end path. And the throwaway solution is not a git repository and has no `AGENTS.md`, so the repository root does not resolve on its own: write an `AGENTS.md` beside the solution, or pass `-p:PurviewSkillsInstallRoot`. Never assert runner behaviour by letting a test actually invoke `npx`/`pnpm dlx` — that downloads from the network and is not a unit of this SDK. - -### Installing from an explicit source - -`PurviewSkillsSource` installs straight from a git source, bypassing package resolution, for a repository that wants a Purview skill it has no `PackageReference` for: - -```bash -dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/engineering-playbook -``` - -The caller owns the ref in that case — append `#` to pin it. This path needs no restore, so `PRSKL0001` does not apply. +`CheckPurviewSkills`/`FixPurviewSkills` (`EnforcePurviewSkillDirectories`) do nothing unless `PurviewSkillsAgents` names agents. Then the only agent skill directories allowed are those agents', per the `PurviewSkillsAgentDirectory` item group (copied from the CLI's agent table — extend it, don't hard-code), plus `.agents/skills`, the canonical store every agent directory links into. The authored `skills/` folder is protected even though openclaw installs there. Fix deletes links only (never their targets — `RemoveDirectory` on Windows, `unlink` on Unix) and folders left empty; a real file is kept and reported as `PRSKL0015`, because it cannot be told from hand-written content. Note the CLI writes copies, not links, for some agent selections, so those are kept too. -Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillResolutionTests.cs`, including the guarantee that an ordinary restore-and-build writes no lockfile and creates no agent directory. +Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillInstallCommandTests.cs` (install commands, runner mapping and repository-root resolution) and `PurviewSkillUpdateTests.cs` (update, check and fix), with the lockfile writer in `SkillLockFileFixtures.cs`. +Two harness facts to know when adding tests here. `BuildingProject` is only `true` when the `Build` target actually runs, so a test that names a skill target but needs to look like a build must pass `-p:BuildingProject=true` itself — `PurviewSkillTargetIsolationTests` does exactly that to prove the `PRSKL0009` guard fires. And the throwaway solution is not a git repository and has no `AGENTS.md`, so the repository root does not resolve on its own: write an `AGENTS.md` beside the solution, or pass `-p:PurviewSkillsInstallRoot`. Never assert runner behaviour by letting a test actually invoke `npx`/`pnpm dlx` — that downloads from the network and is not a unit of this SDK; `PurviewSkillsCommand=echo` stands in for the CLI. ## Repository map - `src/src/SkillsTesting/` — packable agent-skill rule engine (`Purview.Skills.Testing`) @@ -306,7 +285,9 @@ Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillResolutionTests.c - `src/tests/Analyzers.IntegrationTests/` — analyzer integration tests (Roslyn end-to-end analyzer/suppressor/code-fix behavior) - `src/tests/SkillsTesting.UnitTests/` — skill rule tests, against fixture skills folders - `src/tests/BuildSdk.IntegrationTests/` — integration harness validating SDK behaviour +- `docs/wiki/` — the wiki purview.dev publishes: three root pages, everything else in one topic folder (see below) - `skills/` — the agent skills this repository authors and publishes +- `.build/just/` — the `just` modules the root `Justfile` imports, copied from the `purview-buildsdk-justfile` skill - `src/BuildSdk.slnx` — solution entry point ## Test project placement and namespace conventions @@ -347,10 +328,34 @@ Prefer `just` tasks: - `just skills-add ` — install a skill from another repository into `.agents/skills/` - `just skills-update` — `UpdatePurviewSkills` then `FixPurviewSkills` - `just skills-check` / `just skills-fix` — enforce the `PurviewSkillsAgents` layout -- `just skills-recommend` — `RecommendPurviewSkills` (was `skills-check`) +- `just skills-install` — restore `skills-lock.json` (`skills experimental_install`) +- `just version` — the version in `package.json` +- `just bump major|minor|patch` — bump it, resetting the parts after the one bumped, then `version-sync` - `just version-sync` — rewrite the `Purview.BuildSdk` pins in the docs, `templates/global.json` and `.editorconfig` marker after bumping `package.json` - `just version-sync-check` — the same check without writing; what the pre-commit hook runs +- `just properties-check` — every SDK property against `Sdk/Sdk.props.buildschema.json` +- `just pipeline-pr` / `just pipeline-pack-validate` / `just pipeline-local-release` — the Purview.Build + pipeline locally +- `just scrub` — stop the build servers, delete `bin/`/`obj/`/`artifacts/`, restore + +### How the Justfile is arranged + +The root `Justfile` imports one barrel, `.build/just/index.just`, and holds only what is specific to +this repository: `solution_file`, `version-sync`, `properties-check`, `test-linux`, `skills-validate`, +and the `_after-bump` hook that runs `version-sync`. Everything else comes from the modules in +`.build/just/`. + +**Those modules are the published `purview-buildsdk-justfile` skill's `assets/just/`, copied verbatim.** +Change a module in `skills/purview-buildsdk-justfile/assets/just/` and copy it to `.build/just/`; +`JustModuleParityTests` fails while the two differ, and fails if `.build/just/` holds a file the +skill does not ship. Editing only the `.build/just/` copy would leave consumers with the old recipe, +and the next copy would overwrite the edit. `docs.just` is deliberately absent here: the barrel +imports it with `import?`, and this repository has no pm-docs-site. + +The root sets `allow-duplicate-variables` and `allow-duplicate-recipes` so that its definitions +override a module's defaults. The flip side is that a recipe left in the root with a module's name +silently replaces the module's, so do not copy one back. Nothing packed carries a hand-maintained version. `Props/PackedVersion.props` (the `global.json` fallback when the SDK is not in a versioned package folder) is stamped at pack time from @@ -392,8 +397,8 @@ requirement, not a preference: keep it that way; never introduce fixed absolute or Windows-style paths. For authoring conventions, filtering syntax and troubleshooting, see the -[`purview-buildsdk-testing` skill](./skills/purview-buildsdk-testing/SKILL.md) and its -[`treenode-filter` reference](./skills/purview-buildsdk-testing/references/treenode-filter.md). +[`purview-buildsdk-testing` skill](skills/purview-buildsdk-testing/SKILL.md) and its +[`treenode-filter` reference](skills/purview-buildsdk-testing/references/treenode-filter.md). ### Integration harness for complex validation @@ -415,15 +420,40 @@ Use `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs` when validat Supporting files: - `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.Builder.cs` -- `src/tests/BuildSdk.IntegrationTests/TestHelpers.cs` +- `src/tests/BuildSdk.IntegrationTests/Infra/TestHelpers.cs` ## Conventions to preserve -- Keep project naming aligned with repo conventions in [`README.md`](./README.md#project-naming-guide). +- Keep project naming aligned with repo conventions in [`README.md`](README.md#project-naming-guide). - Respect Central Package Management in `Directory.Packages.props`. - Avoid unrelated refactors or formatting-only churn unless requested. - Follow existing style and keep changes small and testable. +## Documentation + +The written surfaces are the authored skills, this file, the READMEs and `docs/wiki/`. They are +kept true to the **code**, never to each other, by the published +[`purview-buildsdk-docs-sync`](./skills/purview-buildsdk-docs-sync/SKILL.md) skill. A settings change +also follows the repository-local `purview-buildsdk-settings-docs-sync` sweep. + +- **READMEs.** `README.md` is the repository README *and* the `Purview.BuildSdk` package README: + `BuildSdk.csproj` packs it. `src/src/SkillsTesting/README.md` is the `Purview.Skills.Testing` + package README, because a packable project's own `README.md` wins over the repository's. +- **The wiki is hierarchical.** purview.dev publishes `docs/wiki/` and reads `_Sidebar.md` for page + order, so a page's path is its URL. + - Three pages stay at the root because the site depends on them: `Getting-Started.md` is its + `docs.rootPage`, and `Configuration-Reference.md` and `Repository-Bootstrap.md` are its `docsPage` + deep links. + - Every other page lives in exactly one topic folder: `concepts/`, `configuration/`, `testing/`, + `analyzers/`, `packaging/`, `tooling/` or `development/`. + - Every page is listed once in `_Sidebar.md` and in its section's table in `Home.md`, under a + `## ` heading that matches the sidebar label. +- **`DocumentationWikiTests` enforces all of that**, plus link resolution and one `# ` title per page, + and checks every `README.md`/`AGENTS.md` link into the wiki. +- **Moving a page changes its published URL.** Give the user the old and new slugs for the site's + `docs.redirects` (in `purview-dev/purview-dev`, `src/src/data/projects/build-sdk.yml`). +- `PropertySchemaTests` reads property tables from every wiki page, in any folder. + ## Release and commit workflow - Versioning is driven by `package.json`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2786bf7..5632892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,178 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver `version` field in `package.json`, which is the single source of truth: a merge to `main` publishes that version to nuget.org. +So changes are recorded under the heading for the version in `package.json`, not under +`[Unreleased]`: an entry parked there would read as unreleased until it was already live. `[Unreleased]` +is used only while the version is a pre-release (`-alpha`, `-beta`, `-rc`). + This file was reconstructed from the conventional-commit history at 1.0.7, so entries before that release summarise what the commits recorded rather than what was written at the time. -## [Unreleased] +## [2.1.0] - 2026-11-13 + +### Added + +- **csproj IntelliSense for every SDK property.** The package now ships + `Sdk/Sdk.props.buildschema.json`, an [MSBuild Editor](https://marketplace.visualstudio.com/items?itemName=mhutch.msbuildeditor) + sidecar describing each of the SDK's own properties: description, type (enumerations included) and + default, and whether it must be set before the `Sdk.props` import. Install the extension to get + completion in any project that imports the SDK; without it the file is inert. Properties MSBuild and + the .NET SDK already describe are not redescribed. +- **The schema is held to the code.** `PropertySchemaTests` fails when a property is defined with no + schema entry (or the reverse), a `CompilerVisibleProperty` or analyzer `build_property.*` read is + undescribed or unexported, a schema default differs from the evaluated default, a pre-import claim + does not match what `Sdk.props` reads, a README or wiki table omits a property or states another + default, the schema fails the MSBuild Editor meta-schema (vendored, validated offline), or the + packed `.nupkg` lacks the sidecar. `just properties-check` runs them. It replaces the hand-maintained + list in `CompilerVisiblePropertyTests`. +- **`ValidatePurviewPropertySchema`**, an opt-in target for packages that ship their own + `.props`/`.targets` and sidecars: `EnablePurviewPropertySchemaValidation=true` runs it before `Pack` + and fails on `PRPSC0001` (an undescribed `CompilerVisibleProperty`), `PRPSC0002` (a described property + nothing defines), `PRPSC0003` (an unreadable sidecar) and `PRPSC0004` (a sidecar MSBuild Editor will + never load because it is not named `.buildschema.json`). `PurviewPropertySchemaDirectory` and + `PurviewPropertySchemaIgnoredProperties` configure it. Off by default: no consumer build changes. +- The `purview-buildsdk-msbuild-properties` skill: adding, renaming, deprecating and removing a property, + when it must be set before the import, when to make it compiler-visible, and where a sidecar must sit + for `Sdk/`, `build/` and `buildTransitive/` deliveries — and declaring a new sidecar in + `.config/purview-build.json` `PackValidation.RequiredContent`, which Purview.Build's pack validation + otherwise rejects as undeclared content. +- **The `purview-buildsdk-justfile` skill: a shared, modular `Justfile`.** The root `Justfile` imports + one barrel, `.build/just/index.just`, over modules the skill ships in `assets/just/` and a repository + copies verbatim: `dotnet.just` (`ensure-tools`, `restore`, `build`, `clean`, `test`, `pack`), + `version.just` (`version`, and `just bump major|minor|patch`, which resets the parts after the one + bumped), `utilities.just` (`lint-check`, `lint-fix`, `vs`, `scrub`), `purview-build.just` + (`pipeline-pr`, `pipeline-build`, `pipeline-tests`), `skills.just` (every `skills-*` target), and + `env.just`, which exports telemetry opt-outs and `DOTNET_CLI_ENABLEAOT` to every recipe without overriding + a value already set. It deliberately does not force the MSBuild server on: a shared server keeps the + NuGet settings it first read, which breaks a build under another `nuget.config` (`MSB4242`). Two modules are + optional, switched on by copying the file: `purview-build-nuget.just` (`pipeline-pack-validate`, + `pipeline-local-release`, `pipeline-release`) and `docs.just` (`docs`, `docs-install`, `docs-check` for + a pm-docs-site project site). A module's variables are defaults the root `Justfile` overrides. +- **The `purview-buildsdk-docs-sync` skill: keep skills, agent instructions, READMEs and the wiki true.** + It treats the code as the only source of truth — every claim is checked against the `.props`/ + `.targets`, the `Justfile`, the analyzer descriptors and the tests, never against another document — + and runs a change as a sweep across all four surfaces. It works out which README a package actually + ships (a project's own `README.md`, else the repository's; `Sdk/*.md` packs to the package root), and + keeps `docs/wiki/` hierarchical: root pages only where the documentation site depends on their URL, + everything else in one topic folder, `_Sidebar.md` and `Home.md` in step. Its references cover how to + check each kind of claim and a wiki-structure test to add. +### Fixed + +- **A relative `RootPackageJson` resolves from the project directory.** It resolved against the build's + working directory, so one build read a different `package.json` - or none - depending on where + `dotnet` was started. An absolute path is unchanged; in `Directory.Build.props` prefer + `$(MSBuildThisFileDirectory)package.json`, because a relative value resolves per project. +- **A file linked beneath `Sdk/` packs where a physical file at that path would.** `IncludeLinkedSdkFiles` + stripped `Sdk/` from every link, so a linked `Sdk/Props/X.props` packed as `Props/X.props`. It now + applies the `PurviewAutoSdkPack` table: `build/`, `buildTransitive/`, `buildMultiTargeting/`, `.github/` + and top-level documents and images to the root, everything else under `Sdk/`. `SdkFolderPackingTests` + packs both kinds side by side. +- **The property schema no longer says to set `SourceLinkPackageName`, `DisableSourceLink`, + `AutoIncludeUsings`, `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension`, `PurviewAutoSdkPack`, + `IsSharedProject` or `TargetProjectName` before the import.** `Sdk.props` reads each only on items, which + MSBuild evaluates after the project body, so a project file works. `PropertySchemaTests` now counts only + property-pass reads as pre-import. +- **An explicit `TargetFramework` or `TargetFrameworks` always wins over the curated target framework + sets.** The SDK only recognised an unconditional declaration in the project body. A value set in + `Directory.Build.props` (before or after the import), under a `Condition`, or in a file the project + imports left the set's `TargetFrameworks` list in place underneath it: NuGet restored the set's + frameworks while the build compiled the explicit one, and the build failed with `NETSDK1005`. The + inverse - a single-entry set such as `Latest` plus an explicit `TargetFrameworks` - silently built one + framework. A new `Targets/TargetFrameworkOwnership.targets`, imported by the .NET SDK through + `BeforeMicrosoftNETSdkTargets`, now withdraws the SDK's value once the project has set its own. Inner + builds are untouched, so the TFM list stays identical across Visual Studio's evaluations. A value set + in `Directory.Build.targets` is still too late to take part. Projects that did not set a TFM, or set + one unconditionally in the project body, are unaffected. +- `PurviewConditionedProjectDeclaration` no longer fires for a conditioned `TargetFramework` or + `TargetFrameworks`, because that declaration is now honoured. It still fires for `IsRoslynComponent`, + `IsRoslynComponentOnly`, `IsPackable`, `PurviewTargetFrameworkSet` and `RootNamespace`. ### Changed +- **`TreatWarningsAsErrors` defaults to `true` for every project**, where before only Roslyn components + got it. Compiler, analyzer and NuGet warnings now fail the build; MSBuild `` tasks (the + SDK's own `PRBLZ0001`, for example) are unaffected. A repository that builds with warnings today + will fail on upgrade: fix them, or set `false` in + `Directory.Build.props` (or one project) to keep the old behaviour. A value you set always wins. +- **The skill targets are a thin wrapper over the standard skills CLI, with nothing bespoke.** + `InstallPurviewSkills` runs `skills add "" --skill "*"` for `PurviewSkillsSource` (append + `#` to pin), or `skills experimental_install` to restore `skills-lock.json` without one. + `UpdatePurviewSkills` re-adds every lockfile source at its latest. `CheckPurviewSkills` and + `FixPurviewSkills` are unchanged. All run from the repository root, once per solution. - **The C# 15 updated memory-safety rules are off by default.** A `net11.0`+ target no longer compiles with `updated-memory-safety-rules` unless asked. The updated model is an experimental compiler feature, so it is an opt-in: set `MemorySafetyRules=2` to enable it. Tier `1` (preview pointer syntax, which also selects `LangVersion=preview`) and tier `0` (off) are unchanged, and an unrecognised tier still fails with `PRCFG0001`. `AllowUnsafeBlocks` remains at its compiler default (`false`). Both .NET 11 compiler opt-ins are now opt-in, alongside Runtime Async. +- **`purview-buildsdk-skills-justfile` is folded into `purview-buildsdk-justfile`.** Its recipes are now + the `skills.just` module and its guidance `references/skills-recipes.md`, so the two cannot disagree. + The recipes name the solution unconditionally. A repository that installed the old skill keeps it until + it is removed: run `npx skills add purview-dev/build-sdk` for the new one and + `npx skills remove purview-buildsdk-skills-justfile` for the old. In an existing `Justfile`, + `ensure-pipeline-tool` and `restore-tools` become `ensure-tools`, the `current_version` recipe becomes + `version`, and `Justfile.skills.just` is replaced by the barrel. +- This repository's own `Justfile` uses the modules, keeping only `version-sync`, `properties-check`, + `test-linux` and `skills-validate`. `just bump` runs `version-sync` afterwards. + +### Removed + +- **The package-to-skill mapping**, which read a `.agents/skills/` folder inside each restored package + plus its nuspec commit. No package built by this SDK carried one, so it never found anything. With it + went `ListPurviewSkills`, `RecommendPurviewSkills` and `VerifyPurviewSkills`, the + `ResolvePurviewSkillSources` and `EvaluatePurviewSkillState` tasks, `PurviewSkillsContinueOnError`, + and `PRSKL0001`, `PRSKL0002`, `PRSKL0010`-`PRSKL0014` and `PRSKL0017`. Install a repository's skills + with `-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo#`, or `bun x skills add` directly. + The `skills-list`, `skills-recommend` and `skills-verify` `just` recipes are gone with them. ### Documentation +- **The wiki is organised into topic folders.** `docs/wiki/` now has `concepts/`, `configuration/`, + `testing/`, `analyzers/`, `packaging/`, `tooling/` and `development/`, with + `Getting-Started.md`, `Configuration-Reference.md` and `Repository-Bootstrap.md` kept at the root + because purview.dev links to them. `Home.md` is a sectioned index, `_Sidebar.md` carries the same + sections, and `Performance.md`, which was missing from the sidebar, is listed. A new + `tooling/Justfile.md` describes the shared `just` modules. `DocumentationWikiTests` holds the + structure, the indexes and every link to it, and `PropertySchemaTests` now reads property tables + from every folder. +- **The README, wiki, skills and agent instructions were audited against the code**, and these were + wrong: + - **Pre-import claims.** `SourceLinkPackageName`, `DisableSourceLink`, `AutoIncludeUsings` and + `PurviewSharedTestingOutputType` were documented as "too late in a `.csproj`". All four work + there, because the SDK reads them only on items, or in `Sdk.targets`. + - **`RootPackageJson`.** It was said to resolve a relative path from the project directory. It + resolves from the build's working directory, so the docs and the schema now recommend an absolute + path. + - **Missing `package.json`.** A git repository with no root `package.json` fails the build. That + is now stated, and `Getting-Started.md` gains a `package.json` step. + - **Detection lists.** `IsAspireHostProject` also matches `Aspire.AppHost.Sdk`. `IsCLIProject` is + a name rule. `*.ClientShared`/`ClientShared` are client-side names. Test detection accepts any + `.Tests` suffix, and the 53 `TestType` names only drive `InternalsVisibleTo`. + - **Repository root discovery.** The bootstrap's default walk (`Markers`) starts no git process, and + `git rev-parse` runs only with `PurviewRepositoryRootStrategy=Git`. + - **Templates and examples.** `templates/Directory.Packages.props` pins versions; it does not float + them. The assembly identifier example was wrong. The quick-reference commands produced a nested + folder. + - **Analyzers and code fixes.** The `PDS0004` acronym lists were incomplete. The `PDS0002` code fix + and the Move/Rename extensions refactoring are separate things. + - **Skills.** Packages built with this SDK carry no `.agents/skills/` folder, so package-driven skill + resolution finds nothing for them. The docs no longer claim otherwise. + - **Pipeline.** `pipeline-tests` packs and validates like the other pipeline recipes. + - **Published skills.** They no longer carry guidance about working in this repository. +- **Configuration tables corrected against the code.** `IncludeSource` was documented as `true` for + packable projects; it is `false` for every project, and the documentation now says so (the default is + unchanged). Properties that are unset by design (`PurviewRuntimeAsync`, `PurviewBrowserSafe`, + `DisablePurviewServerSharedReferenceCheck`, `PurviewSkillsDryRun`, ...) no longer claim a `false` + default. About fifty properties that were undocumented, or documented only in prose, now have rows, + including new *Target framework sets*, *Project detection*, *Blazor WebAssembly and browser-safe + libraries* and *Roslyn components and analyzers* tables in the README. +- The README and wiki *Compiler-visible SDK properties* tables now list every exported property; 22 + were missing from the README and 41 from the wiki. +- `PurviewHomepage`, `PurviewProjectUrl` and `PurviewDocsUrl` were documented as SDK properties. They are + this repository's own convention in `src/Directory.Build.props`; the SDK neither defines nor reads + them, and the docs now say that. +- Removed the README's `Sdk/.agents/**` packing row and the generated-`.gitignore` paragraph, which + described the agent-folder mirror removed earlier. - Made required settings and versions explicit in the README quick start, the wiki, and the `purview-buildsdk-adoption` and `purview-buildsdk-configuration` skills: `NamespacePrefix` is required and pre-import, the SDK version lives in `global.json`, the product version comes from @@ -31,7 +187,7 @@ release summarise what the commits recorded rather than what was written at the rule. - Documented the compiler-visible properties (`CompilerVisibleProperty` → `build_property.*`) for analyzers and source generators. -- **Documented SourceLink end to end.** A new [SourceLink](docs/wiki/SourceLink.md) wiki page, a README +- **Documented SourceLink end to end.** A new [SourceLink](docs/wiki/packaging/SourceLink.md) wiki page, a README section, and skill coverage now explain how to point `SourceLinkPackageName` at the provider for the repository host — including `Microsoft.SourceLink.AzureRepos.Git` for Azure DevOps Services and `Microsoft.SourceLink.AzureDevOpsServer.Git` plus a `SourceLinkAzureDevOpsServerGitHost` item for @@ -43,6 +199,38 @@ release summarise what the commits recorded rather than what was written at the ### Fixed +- **A Blazor WebAssembly client builds again.** The multithreaded build added in 2.0.0 broke every + `net11.0` WebAssembly client: the WebAssembly SDK's `ReadWasmProps` task comes from a custom task + factory that cannot run in the out-of-process task host `-mt` moves it to, so the build failed with + `MSB4060` from `WasmApp.Common.targets`, an error that names nothing leading back to the + `Directory.Build.rsp` this SDK writes. The response file is per repository, so a client now decides + for it: with `PurviewMultiThreadedBuild` unset, a client writes `-mt:false` and a line naming itself, + and every other project leaves that file alone while the named client exists. A repository with no + client still gets `-mt`. A `PurviewMultiThreadedBuild` you set always wins, and a file without the + marker line is still never rewritten. The default for `PurviewMultiThreadedBuild` (`true`) is now + applied in `Sdk.targets`, after the project body, so the SDK can tell a value you set from its own + default. +- **`PRBLZ0002`: a WebAssembly client built under `-mt` stops before the WebAssembly SDK fails.** This + covers `-mt` from the command line, from a response file you own, or from `PurviewMultiThreadedBuild=true`. + The error fires before `PrepareForBuild`, says whether `-mt` came from the command line or which + `Directory.Build.rsp`, and gives the fix (`false`, + or `-mt:false`). The first build after upgrading from 2.0.x has already read the old managed `-mt` + file, so it rewrites the file, stops with `PRBLZ0002` saying so, and the next build succeeds. MSBuild + 18 exposes no property for `-mt`, so the check looks at where an inline task runs: under `-mt`, always + the out-of-process task host. A `net10.0` client builds under `-mt` and is not checked. The Blazor + tests had missed this for two reasons: every harness solution opts out of the response file, and every + Blazor fixture targets `net10.0`. `BlazorWebAssemblyMultiThreadedBuildTests` builds `net11.0` clients + with it on. +- **A parallel build no longer fights over `Directory.Build.rsp`.** Every project reads and may rewrite + the one response file, and in a parallel build they did both at once. A project reading it while + another replaced it failed with `MSB4184 ... being used by another process`, and a library that read + the file before the WebAssembly client rewrote it could write `-mt` back afterwards, so the next build + stopped with `PRBLZ0002`. The file is now read sharing it with writers, with retries, and a file that + stays unreadable is left alone instead of failing the build. Each write takes a lock shared by every + project, looks at the file again, and a project without its own `PurviewMultiThreadedBuild` keeps a + file that names a WebAssembly client that exists. Removing an explicit + `false` from a repository with a client now + ends on the client's `-mt:false` form in one build. - **`DisableSourceLink=true` now actually opts out of SourceLink.** `Sdk.props` re-assigned `DisableSourceLink` to `false` for every non-Roslyn project, clobbering a repository's pre-import value, so the documented opt-out silently had no effect and the GitHub provider was always added. The @@ -430,7 +618,7 @@ dotnet msbuild -t:VerifyPurviewSkills `IsRoslynComponentOnly`, `IsPackable`, `TargetFramework(s)`, `RootNamespace`, `PurviewTargetFrameworkSet`) each used to re-read and re-strip the file, so every evaluation of every project paid six times over. Measured at ~0.59 ms saved per project, ~117 ms on a 200-project - solution — see [`docs/wiki/Performance.md`](docs/wiki/Performance.md). + solution — see [`docs/wiki/Performance.md`](docs/wiki/development/Performance.md). ### Fixed @@ -498,7 +686,9 @@ dotnet msbuild -t:VerifyPurviewSkills - `.editorconfig` values missing test projects. - Source-generator resolution on pack. -[Unreleased]: https://github.com/purview-dev/build-sdk/compare/v1.3.2...HEAD +[2.1.0]: https://github.com/purview-dev/build-sdk/compare/v2.0.1...v2.1.0 +[2.0.1]: https://github.com/purview-dev/build-sdk/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/purview-dev/build-sdk/compare/v1.3.2...v2.0.0 [1.3.2]: https://github.com/purview-dev/build-sdk/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/purview-dev/build-sdk/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/purview-dev/build-sdk/compare/v1.2.0...v1.3.0 diff --git a/Justfile b/Justfile index 2f35a0f..5e29459 100644 --- a/Justfile +++ b/Justfile @@ -1,99 +1,38 @@ +# Recipes for working on Purview.BuildSdk. Run `just` for the list. +# +# The shared recipes - .NET, version, pipeline, agent skills and utilities - come from .build/just/, +# imported through its barrel. Those files are the purview-buildsdk-justfile skill's assets, copied +# verbatim: change a recipe in skills/purview-buildsdk-justfile/assets/just/ and copy it back +# (JustModuleParityTests fails while the two differ). This file holds only what belongs to this +# repository - the values the modules read, and the recipes nothing else needs. + set quiet -import "Justfile.skills.just" +# Let a definition here override a module's default variable or no-op hook. +set allow-duplicate-variables +set allow-duplicate-recipes -solution_file := "src/BuildSdk.slnx" -build_configuration := "Debug" -artifacts_folder := "./artifacts" +import ".build/just/index.just" -pipeline_tool := "dotnet purview-build" +solution_file := "src/BuildSdk.slnx" -current_version := `bun -p "require('./package.json').version"` +# ValidatePurviewSkills is the pack-time check and runs in each project it is invoked on, so it names +# the one project that packs - rather than the solution, which would validate the same folder once +# per project. +skills_validate_project := "src/src/BuildSdk/BuildSdk.csproj" [private] default: just --list -# Restore the repository's .NET local tools (.config/dotnet-tools.json) -[private] -ensure-pipeline-tool: - dotnet tool restore - -# Run the PR pipeline (restore, build, lint, tests) -[group('Pipeline')] -pipeline-pr *args: ensure-pipeline-tool - echo "Running PR pipeline..." - {{ pipeline_tool }} {{ args }} - -# Run the build pipeline (restore, build, lint) -[group('Pipeline')] -pipeline-build *args: ensure-pipeline-tool - echo "Running build pipeline..." - {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} - -# Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) -[group('Pipeline')] -pipeline-release *args: ensure-pipeline-tool - echo "Running release pipeline..." - {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} - -# Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) -# Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. -# Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. -# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ -[group('Pipeline')] -pipeline-local-release *args: ensure-pipeline-tool - echo "Running local release pipeline..." - {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} - -# Run the pipeline with tests enabled -[group('Pipeline')] -pipeline-tests *args: ensure-pipeline-tool - echo "Running tests pipeline..." - {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} - -# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing -[group('Pipeline')] -pipeline-pack-validate *args: ensure-pipeline-tool - echo "Running pack + validate pipeline..." - {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} - -# Open the solution in Visual Studio/ Registered application -[group('Utilities')] -vs: - open {{ solution_file }} - -# Build the solution for the specified configuration (default: Release) -[group('Build and Test')] -build *args: - echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" - dotnet build {{ solution_file }} --configuration {{ build_configuration }} {{ args }} - -# Cleans the solution for the specified configuration (default: Release) -[group('Build and Test')] -clean *args: - echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" - dotnet clean {{ solution_file }} --configuration {{ build_configuration }} {{ args }} - -# Restore local .NET tools -[group('Utilities')] -restore-tools: - dotnet tool restore - -# Restore NuGet packages for the solution -[group('Build and Test')] -restore *args: - dotnet restore {{ solution_file }} {{ args }} - -# Displays the current package version from package.json -[group('Build and Test')] -current_version: - echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (defined in package.json and automatically included in the build output through the Purview.BuildSdk package)" +# A bump changes package.json, so the copy-paste pins must follow it. +_after-bump: version-sync # Rewrite the Purview.BuildSdk pins in the docs, starter template and .editorconfig marker # -# Run after bumping package.json. Packed files are stamped at pack time and need nothing. -[group('Build and Test')] +# `just bump` runs this itself. Packed files are stamped at pack time and need nothing. +[doc('Rewrite the Purview.BuildSdk pins in the docs, starter template and .editorconfig marker')] +[group('Version')] version-sync: echo "==> Syncing version pins to {{ GREEN }}{{ current_version }}{{ NORMAL }}" bun scripts/sync-version.ts @@ -102,34 +41,33 @@ version-sync: # # What the pre-commit hook runs, so a stale .editorconfig marker or quick-start snippet cannot be # committed in the first place. -[group('Build and Test')] +[doc('Check the version pins without writing anything; non-zero when a pin is stale')] +[group('Version')] version-sync-check: echo "==> Checking version pins against {{ GREEN }}{{ current_version }}{{ NORMAL }}" bun scripts/sync-version.ts --check -# Run tests for a specific project with a filter (e.g., "/*/*/*/*/", or "/*/*/*/*[Category=Unit]" to run just unit tests) and configuration (e.g., "Release") -[group('Build and Test')] -test filter="/*/*/*/*/" *args: - echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ build_configuration }}{{ NORMAL }}) with filter {{ YELLOW }}{{ filter }}{{ NORMAL }}" - dotnet test --project {{ solution_file }} --configuration {{ build_configuration }} --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} - # Run the SDK integration tests in the same Linux SDK environment used by CI [group('Build and Test')] test-linux: pwsh -NoProfile -File ./scripts/test-linux-docker.ps1 -# Pack all packable projects +# Check every SDK property against Sdk.props.buildschema.json +# +# Definitions, defaults, the compiler-visible list, the README and wiki tables, the meta-schema and +# the PRPSC target. No --ignore-exit-code 8: a filter that matches nothing must fail, not pass. +[doc('Check every SDK property against Sdk.props.buildschema.json')] [group('Build and Test')] -pack artifact_folder=artifacts_folder *args: - echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" - dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} - -# Format the code with CSharpier -[group('Utilities')] -lint-fix: - dotnet csharpier format . +properties-check *args: + echo "==> Checking SDK properties against {{ GREEN }}Sdk.props.buildschema.json{{ NORMAL }}" + dotnet test --project src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj --configuration {{ build_configuration }} --treenode-filter "/*/*/(PropertySchemaTests)|(PropertySchemaValidationTargetTests)/*" {{ args }} -# Check formatting with CSharpier -[group('Utilities')] -lint-check: - dotnet csharpier check . +# Validate the authored skills in skills/ against the Agent Skills specification +# +# The same check the pack runs (PRSKL0005). The broader rule set - names, descriptions, optional +# fields, bundled links, body size - runs as RepositorySkillTests in `just test`. +[doc('Validate the authored skills in skills/ against the Agent Skills specification')] +[group('Agent Skills')] +skills-validate *args: + echo "==> Validating authored skills in {{ BLUE }}skills/{{ NORMAL }}" + dotnet msbuild "{{ skills_validate_project }}" -t:ValidatePurviewSkills -v:n {{ args }} diff --git a/Justfile.skills.just b/Justfile.skills.just deleted file mode 100644 index 65f5455..0000000 --- a/Justfile.skills.just +++ /dev/null @@ -1,74 +0,0 @@ -# Agent-skill recipes, imported by the Justfile. -# -# Every skill target runs once for the whole solution, however many projects it holds, and reads -# every project's restore graph - so the recipes name the solution, not a hand-picked project. It is -# the Justfile's own solution_file, so the two cannot drift apart. -skills_project := solution_file - -# ValidatePurviewSkills is the pack-time check, and runs in each project it is invoked on, so it names -# the one project that packs the skills. -skills_validate_project := "src/src/BuildSdk/BuildSdk.csproj" - -# Install skills from another repository into .agents/skills/, through the skills CLI -# -# This repository's *own* skills are not installed here. They are authored at skills//SKILL.md -# for other repositories to install, and are read from that path - AGENTS.md links to it, and -# `npx skills add purview-dev/build-sdk` is how a consumer gets them. .agents/skills/ is reserved for -# skills installed from other sources, and skills-lock.json is the committed record of those. -# -# This goes through the SDK's own InstallPurviewSkills target rather than calling the CLI directly, -# so the repository exercises the path it ships to consumers. -# -# just skills-add purview-dev/engineering-playbook -# -# Install skills from another repository into .agents/skills/ (append '#' to pin) -[group('Agent Skills')] -skills-add source *args: - echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }} into the agent directories" - dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} {{ args }} - -# Validate the authored skills in skills/ against the Agent Skills specification -# -# The same check the pack runs (PRSKL0005). The broader rule set - names, descriptions, optional -# fields, bundled links, body size - runs as RepositorySkillTests in `just test`. -# -# Validate the authored skills in skills/ against the Agent Skills specification -[group('Agent Skills')] -skills-validate *args: - echo "==> Validating authored skills in {{ BLUE }}skills/{{ NORMAL }}" - dotnet msbuild "{{ skills_validate_project }}" -t:ValidatePurviewSkills -v:n {{ args }} - -# Report which skills the restored packages ship, and whether a runner is available -[group('Agent Skills')] -skills-list *args: - dotnet msbuild "{{ skills_project }}" -t:ListPurviewSkills -v:n {{ args }} - -# Report missing, drifted or unverifiable skills without installing anything -[group('Agent Skills')] -skills-recommend *args: - dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} - -# Update the agent skills by re-adding every unique source in skills-lock.json -# -# The re-add refreshes installed skills and adds any a source has gained; 'skills update' is never -# run. Skills a referenced package ships are re-added at that package's commit. When PurviewSkillsAgents -# is set, the agent directories it does not select are cleaned up afterwards (FixPurviewSkills); -# when it is not, the skills CLI auto-detects agents and the cleanup does nothing. -# -# just skills-update -p:PurviewSkillsDryRun=true -# just skills-update -p:PurviewSkillsRunner=npx -p:PurviewSkillsAgents=universal -# -# Update the agent skills by re-adding every unique source in skills-lock.json -[group('Agent Skills')] -skills-update *args: - dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} - -# Check that only the skill directories of the agents in PurviewSkillsAgents exist -[group('Agent Skills')] -skills-check *args: - dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} - -# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select -[group('Agent Skills')] -skills-fix *args: - dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/README.md b/README.md index 589b00d..f7c8277 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def | **.NET 11 opt-ins** | A `net11.0` target can opt into the C# 15 updated memory-safety rules (`MemorySafetyRules=2`) and Runtime Async (`PurviewRuntimeAsync=true`): both are **off by default**, and `AllowUnsafeBlocks` stays at its compiler default (`false`); builds use MSBuild's multithreaded mode through a bootstrapped `Directory.Build.rsp` (see [.NET 11 features](#net-11-features)) | | **ServerShared** | A `ServerShared*` project is referenced automatically by every non-client-side project, and never by a WebAssembly client, browser-safe library, Razor class library or `*.Client`-style project; `PRSHR0001`/`PRSHR0002` report a reference that would still put it in a browser download (see [ServerShared](#servershared)) | | **Blazor and BFF** | Blazor WebAssembly clients, Blazor Web App hosts and BFF hosts are detected; `PurviewBrowserSafe` makes a library the client downloads fail the build on trim, AOT and browser-unsupported APIs, and the client checks every reference sets it (see [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff)) | -| **C# defaults** | `net10.0;net11.0` TFMs (overridable), `LangVersion=latest` (C# 15), `Nullable=enable`, `ImplicitUsings=enable`, deterministic builds | +| **C# defaults** | `net10.0;net11.0` TFMs (overridable), `LangVersion=latest` (C# 15), `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true` (every project; set `false` to opt out), deterministic builds | | **Code style** | `.editorconfig` baked into the package, applied via `EditorConfigFilePath`, and auto-bootstrapped to repo root if missing; `EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`. Style policy: `dotnet_style_require_accessibility_modifiers=omit_if_default`, private instance fields named `_camelCase`, and `IDE0040`/`CA1515`/`CA1852`/`CA1034` at warning or above, enforced by `ValidatePurviewStylePolicy` (opt out with `DisablePurviewStylePolicyValidation=true`) | | **NuGet packaging** | `AssemblyName`/`PackageId` default to the fully evaluated `RootNamespace`; packable projects get `GenerateDocumentationFile=true`, `PublishRepositoryUrl=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `EmbedUntrackedSources=true`, and portable PDBs delivered via `.snupkg` (not the `.nupkg`) | -| **Repo bootstrap** | Missing repo-root `.editorconfig`, `global.json` and `Directory.Build.rsp` are auto-created by default (disable via `DisableAutoCopySdkFiles=true`) | -| **CI detection** | `ContinuousIntegrationBuild` set automatically when `CI`, `GITHUB_ACTIONS`, or `TF_BUILD` env vars are present | +| **Repo bootstrap** | Missing repo-root `.editorconfig`, `global.json` and (on a .NET 11 or later SDK) `Directory.Build.rsp` are auto-created by default (disable via `DisableAutoCopySdkFiles=true`) | +| **CI detection** | `ContinuousIntegrationBuild` set automatically when the `CI`, `GITHUB_ACTIONS` or `TF_BUILD` environment variable is `true` | | **SourceLink** | `Microsoft.SourceLink.GitHub` added to every non-test project (`PrivateAssets="all"`), configurable for Azure DevOps, GitLab, Bitbucket and more via `SourceLinkPackageName` (see [SourceLink](#sourcelink)) | | **Purview Telemetry** | `Purview.Telemetry.SourceGenerator` + `Microsoft.Extensions.Telemetry.Abstractions` added by default (opt-out) | | **Assembly info** | Auto-generated `static partial class AssemblyInfo` with `RootNamespace`, `Version`, `Company`, etc., plus an embedded `Microsoft.CodeAnalysis.EmbeddedAttribute` (can be excluded via `PURVIEW_SDK_EXCLUDE_EMBEDDED`). | @@ -44,7 +44,7 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.1" + "Purview.BuildSdk": "2.1.0" } } ``` @@ -84,11 +84,11 @@ Four things have to be right before the SDK behaves as documented: | -- | -- | -- | | `NamespacePrefix` | `Directory.Build.props`, **above** the import | Required. The build fails without it (`ValidateRootNamespacePrefixTarget`) unless `DisableNamespacePrefixCheck=true`. | | The SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | Pinned to a released version, e.g. `2.0.1`. The SDK bootstraps the file when it is missing and records the version it was packed as (`PurviewBuildSdkVersionForGlobalJson`). | -| The product version | repo-root `package.json` → `version` | Applied to both `Version` and `PackageVersion` by default. `UsePackageJsonVersion=Strict` fails the build instead of silently falling back to `0.0.1`. | +| The product version | repo-root `package.json` → `version` | Applied to both `Version` and `PackageVersion` by default. A git repository with no root `package.json` fails the build: add one, or set `UsePackageJsonVersion=false`. `UsePackageJsonVersion=Strict` also fails when no repository root is found at all, instead of silently falling back to `0.0.1`. | | Package versions | `Directory.Packages.props` | Central Package Management is on (`ManagePackageVersionsCentrally=true`). Every `PackageVersion` is a minimum, not an exact pin. | `NamespacePrefix`, `UsePackageJsonVersion` and `RootPackageJson` must be set **before** the ``. -See [Version Detection](docs/wiki/Version-Detection.md) and the +See [Version Detection](docs/wiki/configuration/Version-Detection.md) and the [Configuration Reference](docs/wiki/Configuration-Reference.md). ### 6. Setting the target framework(s) @@ -96,10 +96,11 @@ See [Version Detection](docs/wiki/Version-Detection.md) and the A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, `net10.0;net11.0` (`Current`). Pick a different one in one of three ways, narrowest first: -1. **Declare it in the project** — `TargetFramework` for a single target, `TargetFrameworks` (plural) - for many. It must be declared **unconditionally**: the SDK reads it from the project XML before the - project body is evaluated, so a `Condition` on it is not seen (and is reported as - `PurviewConditionedProjectDeclaration`). +1. **Declare it explicitly** — `TargetFramework` for a single target, `TargetFrameworks` (plural) + for many. An explicit value **always wins** over every set, wherever it is set: in the project + (with or without a `Condition`), in a file the project imports, or in `Directory.Build.props` + before or after the import. Only `Directory.Build.targets` is too late — the .NET SDK has already + decided whether the project multi-targets by then. 2. **Select a curated set** — `Supported` in `Directory.Build.props` or the `.csproj`. Named sets are `Current` (the default), `Latest`, `Supported`, `Broad` and `All`, so a lifecycle change is one SDK bump rather than an edit in every @@ -122,11 +123,11 @@ references, and default package wiring without bespoke overrides. Start with: -- [Engineering Principles](docs/wiki/Engineering-Principles.md) -- [Project Naming Conventions](docs/wiki/Project-Naming-Conventions.md) -- [Project Type Detection](docs/wiki/Project-Type-Detection.md) -- [Assembly Name Generation](docs/wiki/Assembly-Name-Generation.md) -- [Testing Wiring](docs/wiki/Testing-Wiring.md) +- [Engineering Principles](docs/wiki/concepts/Engineering-Principles.md) +- [Project Naming Conventions](docs/wiki/concepts/Project-Naming-Conventions.md) +- [Project Type Detection](docs/wiki/concepts/Project-Type-Detection.md) +- [Assembly Name Generation](docs/wiki/concepts/Assembly-Name-Generation.md) +- [Testing Wiring](docs/wiki/testing/Testing-Wiring.md) ## Project naming guide @@ -189,21 +190,18 @@ about. ```sh # Create a source project -mkdir src/Api && cd src/Api -dotnet new classlib -n Api +dotnet new classlib -n Api -o src/src/Api # Create its unit tests -mkdir ../../tests/Api.UnitTests && cd ../../tests/Api.UnitTests -dotnet new classlib -n Api.UnitTests # SDK wires TUnit automatically +dotnet new classlib -n Api.UnitTests -o src/tests/Api.UnitTests # SDK wires TUnit automatically # Or flat: -mkdir Api.UnitTests && cd Api.UnitTests -dotnet new classlib -n Api.UnitTests +dotnet new classlib -n Api.UnitTests -o Api.UnitTests ``` For the full layout, naming, identity, and test-readability conventions, see -[Engineering Principles](docs/wiki/Engineering-Principles.md) and -[Project Naming Conventions](docs/wiki/Project-Naming-Conventions.md). +[Engineering Principles](docs/wiki/concepts/Engineering-Principles.md) and +[Project Naming Conventions](docs/wiki/concepts/Project-Naming-Conventions.md). --- @@ -215,18 +213,17 @@ The `templates/` folder contains ready-to-copy starter files for new repos: | -- | -- | | `Directory.Build.props` | Bootstrapper — copy to repo root and set `NamespacePrefix` | | `Directory.Build.targets` | Bootstrapper — copy to repo root | -| `Directory.Packages.props` | All default package versions with `*` floating to latest | +| `Directory.Packages.props` | A pinned `PackageVersion` for every package the SDK adds, grouped by version property (`TUnitVersion`, `AspireVersion`, …) | | `global.json` | `msbuild-sdks` entry + `Microsoft.Testing.Platform` test runner | | `.gitignore` | ASP.NET Core + VS + Rider + Node combined gitignore | | `.gitattributes` | Line-ending normalisation for .cs, .json, .yml, etc. | | `.config/dotnet-tools.json` | CSharpier tool manifest | -A package built with `PurviewAutoSdkPack` carries its repository's authored skills at -`.agents/skills/**`, but as *metadata*: nothing copies it out. It is what the SDK reads to map a -restored package to the skills it offers and the commit it was built from. Installing is the skills -CLI's job and nothing else's — see [Agent Skills](docs/wiki/Agent-Skills.md). +The SDK packs no skills: they live in a repository's root `skills/` folder and are delivered by the +skills CLI from git. Installing is the skills CLI's job and nothing else's — see +[Agent Skills](docs/wiki/tooling/Agent-Skills.md). -Repository metadata can also be defined before the SDK import: the package recognizes `PurviewHomepage`, `PurviewProjectUrl`, and `PurviewDocsUrl` for repo/site metadata, which is useful when a package or generated metadata needs to point at a public docs site or project page without hard-coding repo-specific values in each project file. +Repository metadata such as `PackageProjectUrl` is left to the repository. This repository keeps it in one place with its own `PurviewHomepage`, `PurviewProjectUrl` and `PurviewDocsUrl` properties in `src/Directory.Build.props`; they are a convention, not SDK properties — the SDK neither defines nor reads them. `.editorconfig` and `global.json` are bootstrapped into the repository root. Every project in a solution writes those same destinations, so a write is staged into a temporary file and renamed into @@ -239,7 +236,7 @@ to a low-importance message) and only a copy that still fails after every retry The bootstrapped `.editorconfig` opens with a version marker: ```editorconfig -# Purview.BuildSdk 2.0.1 - managed file, do not edit +# Purview.BuildSdk 2.1.0 - managed file, do not edit ``` That line is how the SDK recognises a file **it** wrote. When the SDK version changes, a file carrying @@ -314,70 +311,62 @@ which is worth roughly 0.7s per build. ### Agent skills -Purview skills are published in the standard [Agent Skills](https://skills.sh/) format and installed -with the standard `skills` CLI. The SDK is the discovery and compatibility half only: it reads the -skills each referenced NuGet package ships, compares them against the CLI's own `skills-lock.json`, -and recommends the command that fixes the difference. It never installs, updates or removes a skill -as a side effect of a restore or a build. +Skills are published in the standard [Agent Skills](https://skills.sh/) format, in a repository's +root `skills/` folder, and installed with the standard `skills` CLI (`bun x skills add owner/repo`). +The SDK adds nothing bespoke: its targets are a thin wrapper that runs that CLI from the repository +root, once per solution, and keeps the agent directories tidy. Nothing about skills runs during a +restore or a build. ```bash -bun x skills add purview-dev/build-sdk # install, the ordinary way -dotnet msbuild -t:RecommendPurviewSkills # what is missing, drifted or unverifiable -dotnet msbuild -t:InstallPurviewSkills # explicit; delegates to the CLI -dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills -dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist -dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory +bun x skills add purview-dev/build-sdk # install, the ordinary way +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo # the same, through the SDK +dotnet msbuild -t:InstallPurviewSkills # restore skills-lock.json +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source at its latest +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` These run only when named: install, update and fix fail with `PRSKL0009` if anything pulls them into -a build, a restore or a design-time build. `UpdatePurviewSkills` re-adds each source a restored package ships at the commit that package version -was built from, so after upgrading a package reference it lands on the matching skills. Every other -source is re-added at its latest. `PurviewSkillsAgents` is empty by default, so the CLI auto-detects -agents; set it (for example `universal`) to choose, and `CheckPurviewSkills`/`FixPurviewSkills` then -enforce that only those agents' skill directories exist. - -Compatibility is pinned by commit: a package's nuspec names the commit it was built from, the CLI -accepts `owner/repo#` and records it, so a project on an older package version is never -advised to install skills describing a newer API. - -**Nothing about skills runs during a build.** `-t:RecommendPurviewSkills` reports the state and -`-t:VerifyPurviewSkills` reports it and fails when a referenced package's skills are missing or pinned -to a different ref — the second is what a CI job calls. Reading the restore graph and the lockfile on -every build of every project was measured as the largest avoidable cost in an up-to-date build, so it -no longer happens unless somebody asks for it. +a build, a restore or a design-time build. Append `#` to a source to pin it. `PurviewSkillsAgents` +is empty by default, so the CLI auto-detects agents; set it (for example `universal`) to choose, and +`CheckPurviewSkills`/`FixPurviewSkills` then enforce that only those agents' skill directories exist. The CLI is launched through a package runner. `PurviewSkillsRunner` defaults to `bun` (`bun x skills`) and also accepts `npx`, `bunx`, `pnpm`, `yarn`, `deno` or `skills`; anything else falls through to ` skills@1`. Because `skills-lock.json` and the agent directories belong to the repository rather than to a project, every skill target runs **from the repository root, always**, and reports `PRSKL0003` rather than falling back to a project folder when that root cannot -be resolved. See [Agent Skills](docs/wiki/Agent-Skills.md). - +be resolved. See [Agent Skills](docs/wiki/tooling/Agent-Skills.md). --- ## Configuration reference -Set any of these properties **before** the `` in your `Directory.Build.props`: +Setting a property in `Directory.Build.props` **before** the `` always works. A property whose +description says *set it before the `Sdk.props` import* only works there; the rest can also be set in a +project file. Every property here is described in the SDK's +[property schema](#msbuild-property-schema-and-intellisense), so the same text appears in csproj +IntelliSense. ### Version detection | Property | Default | Description | | -- | -- | -- | | `UsePackageJsonVersion` | `true` | `true` enables version detection, `false` disables it, and `Strict` requires version detection to succeed (build fails if no version source can be resolved). | -| `RootPackageJson` | *(auto-discovered)* | Explicit path to a `package.json`. Relative paths are resolved from the project directory. | +| `RootPackageJson` | *(auto-discovered)* | Explicit path to a `package.json`. A relative path resolves from the project directory, so in `Directory.Build.props` prefer `$(MSBuildThisFileDirectory)package.json`. | | `EnableVersionDetectionCache` | `true` | Enables local caching of auto-discovered package.json version results. | -| `VersionDetectionLogEnabled` | `false` | Emits a high-importance message showing the detected package version. Set to `true` to enable logging. +| `VersionDetectionLogEnabled` | `false` | Emits a high-importance message showing the detected package version. Set to `true` to enable logging. | +| `VersionDetectionCacheFile` | *(one file per project directory under the temporary directory)* | File the package.json version detection cache is written to. Set it before the `Sdk.props` import. | When `UsePackageJsonVersion=true` (the default) or `UsePackageJsonVersion=Strict`, the SDK: 1. **Explicit path** — if `RootPackageJson` is set, reads that file directly. -2. **Auto-discovery** — otherwise, locates the repo root from CI workspace variables (`GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`), then by walking up from the project directory looking for a `.git` marker or a `package.json`, and reads `package.json` from there. +2. **Auto-discovery** — otherwise, finds the repository root (`RepoRoot`) and reads `package.json` from there: a `RepoRoot` set before the import, else the root cached for this project, else the CI workspace variables (`GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`), else the nearest `.git` and then the nearest `package.json` above the project. -The extracted `version` field is applied to both `Version` and `PackageVersion`. A build error is raised when a `package.json` was resolved but can't be read, or when it contains no `version` field. With `UsePackageJsonVersion=Strict`, the build also fails when no package.json source can be discovered at all (for example, no explicit `RootPackageJson` and no discoverable `.git` marker or CI workspace variable); in non-strict mode that case silently falls back to the `0.0.1` default. +The first `"version"` value in the file is applied to both `Version` and `PackageVersion`. The build fails when an explicit `RootPackageJson` does not exist, when a repository root is found with no `package.json` in it, or when the file has no `version`. With `UsePackageJsonVersion=Strict`, it also fails when no repository root can be discovered at all; in non-strict mode that case silently keeps the `0.0.1` default. See [Version Detection](docs/wiki/configuration/Version-Detection.md). Version detection logging is disabled by default. Set `VersionDetectionLogEnabled` to `true` to emit a high-importance message showing the detected package version. -> **Important — set before the import:** Both `UsePackageJsonVersion` and `RootPackageJson` must be set **before** the `` line in your `Directory.Build.props`. The version logic runs during that import and cannot see properties set afterwards (e.g. in individual `.csproj` files). +> **Important — set before the import:** `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `EnableVersionDetectionCache` and `VersionDetectionCacheFile` must be set **before** the `` line in your `Directory.Build.props`. The version logic runs during that import and cannot see properties set afterwards (e.g. in individual `.csproj` files). > > ```xml > @@ -400,25 +389,29 @@ Version detection logging is disabled by default. Set `VersionDetectionLogEnable | `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` field-naming rule, or adds the accessibility rules to `NoWarn`. | | `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | Semicolon-fenced rule set exempted in test and shared-testing projects, so test classes stay public, `Method_Scenario_Expectation` names keep their underscores, and helpers need no null guards. Override before the SDK import to narrow or extend it. | | `DisablePurviewTestContextRuleSet` | `false` | Set to `true` to make test and shared-testing projects enforce the production API-surface rules as well (the strict style contract applies either way). | -| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects. `Library` (default) keeps them helper libraries - with `IsTestProject`/`IsTestingPlatformApplication` cleared - which also keeps their fixtures out of `CA1515`'s Exe-only scope. Set to `Exe` before the SDK import to keep the test packages' executable/test-host shape instead. | +| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects. `Library` (default) keeps them helper libraries - with `IsTestProject`/`IsTestingPlatformApplication` cleared - which also keeps their fixtures out of `CA1515`'s Exe-only scope. Set to `Exe` (in the project or `Directory.Build.props`) to keep the test packages' executable/test-host shape instead. | | `TargetFramework` | `net10.0;net11.0` | Override the default TFMs per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. | | `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, compiler-generated output under the intermediate directory, no dependency file, telemetry exclusion, and package build output. Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | -| `IsRoslynComponentOnly` | `true` for Roslyn components | Produces an analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, and the analyzer assembly and portable PDB are packed under `analyzers/dotnet/cs/`. Set to `false` for a dual-role Roslyn component that uses normal library symbol packaging. | +| `IsRoslynComponentOnly` | `false` (`true` for Roslyn components) | Produces an analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, and the analyzer assembly and portable PDB are packed under `analyzers/dotnet/cs/`. Set to `false` for a dual-role Roslyn component that uses normal library symbol packaging. | | `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider package, added to every non-test project. Match the repository host: `Microsoft.SourceLink.AzureRepos.Git` for Azure DevOps Services, `Microsoft.SourceLink.AzureDevOpsServer.Git` for on-premises Azure DevOps Server / TFS, `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git`. Set it **before the SDK import**. See [SourceLink](#sourcelink). | -| `DisableSourceLink` | `false` | Set to `true` **before the SDK import** to stop the SDK from adding the SourceLink package at all. | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider package, added to every non-test project. Match the repository host: `Microsoft.SourceLink.AzureRepos.Git` for Azure DevOps Services, `Microsoft.SourceLink.AzureDevOpsServer.Git` for on-premises Azure DevOps Server / TFS, `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git`. Set it in `Directory.Build.props` so every project agrees. See [SourceLink](#sourcelink). | +| `DisableSourceLink` | `false` | Set to `true` to stop the SDK from adding the SourceLink package at all. | | `EnableAssemblyNameGeneration` | `true` | When `true` (default), `AssemblyName` and `PackageId` derive from the fully evaluated `RootNamespace`. When explicitly `false`, standard .NET behaviour applies (`$(MSBuildProjectName)`). Explicit ``/`` in a `.csproj` always take precedence. | | `DisableProjectFileNamingConventionCheck` | `false` | Set to `true` to disable the validation that requires `MyProject\MyProject.csproj` naming alignment. | -| `DisableGenerateAssemblyInfoClass` | `false` | Set to `true` to disable the generated `AssemblyInfo` helper source. | +| `DisableGenerateAssemblyInfoClass` | `false` (`true` for test and shared-testing projects) | Set to `true` to disable the generated `AssemblyInfo` helper source. | | `DisableGenerateAssemblyIdentifierClass` | `false` (`true` for test and shared-testing projects) | Set to `true` to disable the generated assembly identifier marker class. | | `AssemblyIdentifierClassName` | `Assembly` | Overrides the name of the generated assembly identifier marker class. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | | `MemorySafetyRules` | `0` | C# 15 memory-safety tier for `net11.0`+ targets: `0` off (default), `1` preview pointer syntax, `2` updated rules (`updated-memory-safety-rules`). The updated rules are an experimental compiler feature, so they are opt-in. `AllowUnsafeBlocks` is never changed. | -| `PurviewRuntimeAsync` | `false` | Set to `true` to compile `net11.0`+ targets with `runtime-async=on`. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | -| `IsClientSideProject` | *(detected)* | `true` for a project whose assemblies are downloaded to a browser — a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Set explicitly to override; a `ServerShared` project is never added to a client-side project. | +| `PurviewRuntimeAsync` | *(unset, which is treated as false)* | Set to `true` to compile `net11.0`+ targets with `runtime-async=on`. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | +| `IsClientSideProject` | *(detected)* | `true` for a project whose assemblies are downloaded to a browser — a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.ClientShared`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` (or `ClientShared`/`Components`) name. Set explicitly to override; a `ServerShared` project is never added to a client-side project. | | `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project. `false` removes the automatic reference. | -| `DisablePurviewServerSharedReferenceCheck` | `false` | Suppresses `PRSHR0001`/`PRSHR0002`. | +| `DisablePurviewServerSharedReferenceCheck` | *(unset, which is treated as false)* | Suppresses `PRSHR0001`/`PRSHR0002`. | | `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors instead of warnings. | +| `DisableUnconditionalProjectDeclarationCheck` | `false` | Suppresses `PurviewConditionedProjectDeclaration`, raised when `IsRoslynComponent`, `IsRoslynComponentOnly`, `IsPackable`, `PurviewTargetFrameworkSet` or `RootNamespace` is declared only under a `Condition` the SDK cannot evaluate. | +| `AutoGeneratedAssemblyInfoFile` | `GeneratedAssemblyInfo.cs` | File name of the generated AssemblyInfo helper source, written under the intermediate `Properties/` folder. | +| `AutoGeneratedAssemblyIdentifierFile` | `GeneratedAssemblyIdentifier.cs` | File name of the generated assembly identifier source, written under the intermediate `Properties/` folder. | +| `CurrentYear` | *(the current year at build time)* | Year used in generated assembly metadata. | ### Packable project defaults @@ -432,11 +425,11 @@ For projects where `IsPackable=true`, the SDK provides these defaults **only whe | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | | `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | -| `IncludeSource` | `true` | Includes source files in the package. | +| `IncludeSource` | `false` | Packs source files into the symbol package. Off for every project, packable or not; SourceLink is how sources reach the debugger. | Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does **not** receive PDB files unless the project explicitly opts in (for example by adding `.pdb` to `AllowedOutputExtensionsInPackageBuildOutputFolder`). -**Repository README auto-inclusion:** when the repo root is discoverable (`.git` marker or CI workspace variable), the repository-root `README.md` is packed automatically for packable projects and registered via `PackageReadmeFile` — but only when the file exists and `PackageReadmeFile` has not been configured explicitly. The SDK skips the auto-inclusion if a README-named file is already being packed, so no duplicate readme items are produced. No README is required; if the file is absent the pack succeeds without readme metadata. +**Package README:** for a packable project with no explicit `PackageReadmeFile`, the SDK registers `PackageReadmeFile=README.md` and packs a README to the package root. The project's own `README.md`, beside its `.csproj`, wins; otherwise the repository-root `README.md` (at `RepoRoot`) is used. The SDK skips this when a README-named file is already being packed — an `Sdk/README.md`, for example — so no duplicate readme items are produced. No README is required; if neither file exists the pack succeeds without readme metadata. The SDK never forces organization/package-specific metadata — `Authors`, `Company`, `PackageLicenseExpression`, `PackageLicenseFile`, `Description`, `PackageTags`, `PackageProjectUrl`, and repository URLs are left to the repository or individual package. `IsPackable` is not set blindly: it defaults to `false` and only becomes `true` when a project explicitly opts in. @@ -453,13 +446,20 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` at the repository root that enables MSBuild's multithreaded build (`-mt`) on .NET 11. | | `RepositoryDirectoryBuildRspFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `Directory.Build.rsp`. | -| `PurviewMultiThreadedBuild` | `true` | When `false`, a managed `Directory.Build.rsp` is rewritten to `-mt:false` so the mode is actually off. | +| `PurviewMultiThreadedBuild` | `true` | When `false`, a managed `Directory.Build.rsp` is rewritten to `-mt:false` so the mode is actually off. Left unset, a Blazor WebAssembly client writes `-mt:false` for the whole repository, because it cannot build under `-mt` (`PRBLZ0002`); a value you set always wins. | | `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports a file that differs from the SDK-provided one, and `Never` skips bootstrapping. | | `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | | `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a failed bootstrap write is a warning instead of an error. | | `PurviewSuppressCopyRetryWarnings` | `true` | Demotes built-in copy retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt. | +| `PurviewEditorConfigAutoUpdate` | `true` | Refreshes a managed `.editorconfig` (one opening with the SDK's version marker) when the SDK version changes. `false` keeps it exactly as it is. | +| `PurviewEditorConfigAdoptionWarning` | `true` | Warns, once per repository per SDK version, when the repository `.editorconfig` is not one the SDK manages and so will never be updated. | +| `PurviewEditorConfigAdoptionWarningInCI` | `false` | Reports the adoption warning on CI builds too. Off so that advice cannot fail a `TreatWarningsAsErrors` build. | +| `PurviewEditorConfigAdoptionStampPath` | *(.purview/editorconfig-adoption.cache beside the repository .editorconfig)* | Where the once-per-SDK-version record of the adoption warning is kept. | +| `PurviewRepositoryRootStrategy` | `Markers` | How the bootstrap finds the repository root: `Markers` walks up to the nearest root marker without starting a process; `Git` asks git for the worktree root. See [Finding the repository root](#finding-the-repository-root). | +| `EditorConfigFilePath` | *(the .editorconfig inside the installed SDK package)* | The SDK-provided `.editorconfig` injected into `@(EditorConfigFiles)` for build-time style enforcement. Set it before the `Sdk.props` import. | +| `RepoRoot` | *(discovered from CI workspace variables, then a .git marker or package.json above the project)* | Repository root used for version detection and bootstrapping. Set it before the `Sdk.props` import. | ### Sdk folder packaging @@ -469,9 +469,8 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack > **The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` > into the consuming repository; that is gone, because the skills CLI covers it and two writers on -> one set of paths meant the mirror could silently overwrite a pinned install. A package still -> carries `.agents/skills/` as metadata describing which skills it offers and at which commit, -> which is what the SDK reads to recommend an install. +> one set of paths meant the mirror could silently overwrite a pinned install. The SDK packs no +> `.agents/**` content, and reads none from packages: skills come from git. ### Agent skills @@ -479,21 +478,21 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | -- | -- | -- | | `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the skills CLI's lockfile is read from. | | `PurviewSkillsSourceFolder` | `/skills` | Repository folder holding authored skills. | -| `PurviewSkillsAgents` | *(empty — the CLI auto-detects)* | Comma-separated agents the skill targets install to, and that recommended commands name. When set, `CheckPurviewSkills`/`FixPurviewSkills` allow only those agents' skill directories. | -| `PurviewSkillsDryRun` | `false` | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print the CLI commands without running them. | +| `PurviewSkillsAgents` | *(empty, so the skills CLI auto-detects agents)* | Comma-separated agents the skill targets install to, and that recommended commands name. When set, `CheckPurviewSkills`/`FixPurviewSkills` allow only those agents' skill directories. | +| `PurviewSkillsDryRun` | *(unset, which is treated as false)* | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print the CLI commands without running them. | | `PurviewSkillsRunner` | `bun` | Package runner used to launch the skills CLI (`bun`, `npx`, `bunx`, `pnpm`, `yarn`, `deno`, `skills`, or any other). | | `PurviewSkillsRunnerCandidates` | `bun;npx;bunx;pnpm;yarn;deno` | Runners probed on `PATH` after `PurviewSkillsRunner`. | | `PurviewSkillsPackage` | `skills@1` | Package spec the executed launcher pins to. | | `PurviewSkillsInstallRoot` | *(resolved)* | Overrides the repository root every skill target runs from. | | `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root. The first is definitive and searched to the top; the rest resolve nearest-first. | | `PurviewSkillsCommand` | *(detected)* | Overrides the launcher entirely and skips runner detection. | -| `PurviewSkillsSource` | *(none)* | Installs from an explicit git source (`owner/repo`, optionally `#ref`) instead of from referenced packages. | +| `PurviewSkillsSource` | *(none)* | The source `InstallPurviewSkills` adds (`owner/repo`, optionally `#ref`); without it, install restores `skills-lock.json`. | +| `PurviewSkillsRepoRoot` | *(RepoRoot, else the nearest folder with an AGENTS.md)* | Evaluation-time guess at the repository root for the skill targets; a value you set outranks the marker walk. Set it before the `Sdk.props` import. | When a project is packable, the SDK treats any content under `Sdk/` as a pack target with these rules: | Source path | Package path | | -- | -- | -| `Sdk/.agents/**` | `.agents/**` | | `Sdk/.github/**` | `.github/**` | | `Sdk/build/**` | `build/**` | | `Sdk/buildTransitive/**` | `buildTransitive/**` | @@ -501,8 +500,6 @@ When a project is packable, the SDK treats any content under `Sdk/` as a pack ta | `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. | package root | | everything else under `Sdk/` | `Sdk/` | -The SDK automatically adds a `.gitignore` file into each second-level folder under `Sdk/.agents` with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`. This ensures the copied folder structure remains discoverable in consuming repositories while the content itself is ignored by Git. - ### Telemetry | Property | Default | Description | @@ -519,6 +516,84 @@ The SDK automatically adds a `.gitignore` file into each second-level folder und | `TestDataFramework` | `Bogus` | Test data provider. Supported values: `Bogus`, `None`. | | `DisableAutoInternalsVisibleTo` | `false` | Set to `true` to disable automatic `InternalsVisibleTo` generation for test types and shared testing projects. | +### Target framework sets + +An explicit `TargetFramework` or `TargetFrameworks` always wins over every set. See +[Setting the target framework(s)](#6-setting-the-target-frameworks). + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewTargetFrameworkSet` | `Current` | Curated set used when no `TargetFramework`/`TargetFrameworks` is set: `Latest`, `Current`, `Supported`, `Broad` or `All`. Set it before the `Sdk.props` import, or unconditionally in the project file. An unknown name fails with `PurviewInvalidTargetFrameworkSet`; a Roslyn component ignores the sets. | +| `PurviewTargetFrameworksLatest` | `net11.0` | The `Latest` set: the latest shipped release only. Set it before the `Sdk.props` import to redefine the set. | +| `PurviewTargetFrameworksCurrent` | `net10.0;net11.0` | The `Current` set, the default: the current LTS plus the latest release. Set it before the `Sdk.props` import to redefine the set. | +| `PurviewTargetFrameworksSupported` | `net10.0;net11.0` | The `Supported` set: every release still in support. Set it before the `Sdk.props` import to redefine the set. | +| `PurviewTargetFrameworksBroad` | `net8.0;net9.0;net10.0` | The `Broad` set: every shipped release, without committing to the newest major. Set it before the `Sdk.props` import to redefine the set. | +| `PurviewTargetFrameworksAll` | `net8.0;net9.0;net10.0;net11.0` | The `All` set: `Broad` plus the newest major. Set it before the `Sdk.props` import to redefine the set. | + +### Project detection + +The SDK classifies every project before its body is evaluated. Each result is also +[compiler-visible](#compiler-visible-sdk-properties). Override one by setting it before the `Sdk.props` +import (or, where noted, in the project). + +| Property | Default | Description | +| -- | -- | -- | +| `IsCSharpProject` | *(detected from the project file extension)* | `true` for a `.csproj`; every C# default in the SDK is gated on it. | +| `IsSdkProject` | *(detected from the project)* | `true` when an `Sdk` value is found on the project or its import declaration. | +| `SdkProjectName` | *(detected from the project)* | The SDK named by the project or its import, for example `Microsoft.NET.Sdk.Web`. | +| `IsWebProject` | *(detected from the project)* | Marks a project with web behaviour (a web SDK project or an API host). | +| `IsWebSdkProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Web`. | +| `IsWorkerSdkProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Worker`. | +| `IsAspireHostProject` | *(detected from the project)* | `true` when the project SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | +| `IsBlazorWebAssemblyProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Razor`. | +| `IsBlazorHostProject` | *(detected from the project)* | `true` for a web SDK project that references a Blazor WebAssembly client. Says nothing about whether the host is a BFF. | +| `IsBffProject` | *(detected from the project)* | `true` for a web SDK project whose name ends with `Bff`; set `true` or `false` (in the project too) to override. Gates `PDS0008`/`PDS0009`. | +| `IsCLIProject` | *(detected from the project)* | `true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart` or `QuickStarts`. | +| `IsContainerProject` | *(detected from the project)* | `true` when a `Dockerfile` beside the project selects the container defaults. | +| `IsSharedProject` | *(detected from the project name)* | `true` for a `Shared` helper project (`Shared`, `SharedFramework`, ...), which other projects reference automatically. | +| `IsServerSharedProject` | *(detected from the project name)* | `true` for a `ServerShared` helper project, which only projects that cannot run in a browser reference automatically. | +| `IsSharedTestingProject` | *(detected from the project name)* | `true` for a shared testing helper project (`SharedTestingFramework`, ...). | +| `TestingType` | *(detected from the project name)* | Test category taken from a test project's name suffix (`Unit`, `Integration`, `E2E`, ...). | +| `TargetProjectName` | *(detected from the project name)* | For a test project, the project under test, inferred from the test project's name. | + +### Blazor WebAssembly and browser-safe libraries + +See [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff). + +| Property | Default | Description | +| -- | -- | -- | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a Blazor WebAssembly client (packable when asked, container detection, the automatic `Shared` reference). A `ServerShared` project is never added either way. Set it before the `Sdk.props` import. | +| `PurviewAutoBlazorClientReference` | `true` | Lets a host named `Name` reference its `Name.Client` WebAssembly client automatically. | +| `PurviewBrowserSafe` | *(unset, which is treated as false)* | Applies the browser-safe defaults to a class library: `IsAotCompatible`, `SupportedPlatform` browser, and trim, AOT and `CA1416` diagnostics as errors. | +| `PurviewBrowserSafeWarningsAsErrors` | *(true on a PurviewBrowserSafe library, otherwise unset)* | When `false`, a browser-safe library keeps its trim, AOT and `CA1416` diagnostics as warnings. | +| `PurviewBrowserSafeWarningCodes` | *(on a PurviewBrowserSafe library, CA1416, IL2001-IL2128, IL3000-IL3005 and IL3050-IL3059)* | Diagnostics a browser-safe library escalates to errors. | +| `DisablePurviewBrowserSafeReferenceCheck` | *(unset, which is treated as false)* | Turns off `PRBLZ0001`, the check that a WebAssembly client's (or browser-safe library's) project references set `PurviewBrowserSafe`. | +| `PurviewBrowserSafeReferenceCheckAsError` | *(unset, which is treated as false)* | Reports `PRBLZ0001` as an error instead of a warning. | +| `PurviewBffExposesApi` | *(true when the BFF references Microsoft.AspNetCore.OpenApi, otherwise unset)* | Keeps the OpenAPI interceptor namespace on a BFF host. | + +### Roslyn components and analyzers + +| Property | Default | Description | +| -- | -- | -- | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Stops `ValidateRoslynComponentCompilerSettings` failing the pack of a Roslyn component whose compiler defaults were changed. | +| `PurviewPackAnalyzerPdb` | *(true for an analyzer-only Roslyn component, otherwise unset)* | Packs a Roslyn component's PDB beside its analyzer under `analyzers/dotnet/cs/`, because a `.snupkg` cannot carry analyzer symbols. | +| `PurviewSourceGeneratorFrameworkAnalyzerValidation` | *(unset, which is treated as true)* | Set to `false` to turn off `PRSGD0005`, which checks that every file in a Roslyn component's analyzer closure is self-contained. | +| `PurviewAnalyzerClosurePermittedReferences` | `Microsoft.CodeAnalysis;System.Composition;System.Collections.Immutable;System.Memory;System.Threading.Tasks.Extensions;netstandard;mscorlib;System.` | Simple-name prefixes of host-provided assemblies an analyzer-closure file may reference without `PRSGD0005`. | +| `PurviewIncludeComponentReferenceAnalyzerClosure` | `true` | Includes a referenced Roslyn component's analyzer artifact in the referencing component's analyzer closure. | +| `PurviewImportProjectReferenceBuildTransitive` | `true` | Registers the `CompilerVisibleProperty` items a referenced project ships in `Sdk/buildTransitive`, so a package's own properties reach analyzers while developing it through `ProjectReference`. | + +### Property schema validation + +For a package that ships its own `.props`/`.targets` and an MSBuild Editor sidecar. See +[MSBuild property schema and IntelliSense](#msbuild-property-schema-and-intellisense). + +| Property | Default | Description | +| -- | -- | -- | +| `EnablePurviewPropertySchemaValidation` | `false` | Runs ValidatePurviewPropertySchema before Pack, failing it when a .buildschema.json sidecar and the package's .props/.targets disagree (PRPSC0001-PRPSC0004). For a package that ships MSBuild properties. Off, so no ordinary build or pack changes. | +| `PurviewPropertySchemaDirectory` | *(the project's Sdk/ folder, which PurviewAutoSdkPack ships)* | Folder whose .props/.targets files and .buildschema.json sidecars ValidatePurviewPropertySchema checks. | +| `PurviewPropertySchemaIgnoredProperties` | *(empty)* | Compiler-visible properties ValidatePurviewPropertySchema does not require a sidecar entry for, because the package does not own them (Version, RootNamespace, ...). | + ### Compiler-visible SDK properties The SDK exports its properties via `CompilerVisibleProperty`, so analyzers and source generators read them through `build_property.`: @@ -536,17 +611,36 @@ Read the exported property rather than re-deriving SDK behaviour — it is the S | `RepoRoot` | Repo root directory found via `.git` auto-discovery. | | `Version` | Package/assembly version, sourced from `package.json` when detection is enabled. | | `PackageVersion` | NuGet package version, sourced from `package.json` when detection is enabled. | +| `ProjectDir` | Absolute project directory path (with trailing separator) used by analyzers for project-relative file calculations. | +| `RootNamespace` | Effective project root namespace used by analyzers that validate or customize namespace conventions. | | `NamespacePrefix` | Required namespace prefix used to derive `RootNamespace`. | | `DisableNamespacePrefixCheck` | Disables the build error for missing `NamespacePrefix`. | | `TestingFramework` | Selected testing framework (`TUnit`, `Xunit`, or `None`). | | `SubstituteFramework` | Selected mocking provider (`TUnitMocks`, `NSubstitute`, or `None`). | | `TestDataFramework` | Selected test data provider (`Bogus` or `None`). | | `SourceLinkPackageName` | SourceLink package ID added by the SDK. | +| `DisableSourceLink` | Disables automatic SourceLink integration. | | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | +| `PurviewSkillsSourceFolder` | Repository folder holding authored agent skills (default `/skills`). | +| `PurviewSkillsAgents` | Comma-separated agents the skill targets install to (default empty: the CLI auto-detects). | +| `PurviewSkillsDryRun` | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print commands without running them. | +| `PurviewSkillsCommand` | Overrides the launcher entirely and skips runner detection. | +| `PurviewSkillsSource` | The source `InstallPurviewSkills` adds (`owner/repo`, optionally `#ref`); without it, install restores `skills-lock.json`. | +| `PurviewSkillsLockFile` | Where the skills CLI's `skills-lock.json` is read from (default `/skills-lock.json`). | +| `PurviewSkillsInstallRoot` | Overrides the repository root every skill target runs from. | +| `PurviewRepositoryRootMarkers` | Ordered markers identifying a repository root (default `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json`). | +| `PurviewSkillsRunner` | Package runner used to launch the skills CLI (default `bun`; also `npx`, `bunx`, `pnpm`, `yarn`, `deno`, `skills`). | +| `PurviewSkillsRunnerCandidates` | Runners probed on `PATH` after `PurviewSkillsRunner` (default `bun;npx;bunx;pnpm;yarn;deno`). | +| `PurviewSkillsPackage` | Package spec the executed launcher pins to (default `skills@1`). | +| `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | +| `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | Base delay between bootstrap write attempts (default `500`). | +| `PurviewRepoBootstrapCopyFailureAsError` | When `true` (default), a bootstrap write that still fails after every retry is an error. | +| `PurviewSuppressCopyRetryWarnings` | When `true` (default), built-in copy retry notices (`MSB3026`) are demoted to messages. | +| `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. | | `DisableGenerateAssemblyInfoClass` | Disables generated `AssemblyInfo` helper source. | | `DisableGenerateAssemblyIdentifierClass` | Disables the generated assembly identifier marker class. | -| `AssemblyIdentifierClassName` | Name of the generated assembly identifier marker class. | | `EnableAssemblyNameGeneration` | When `true` (default), `AssemblyName` derives from `RootNamespace`. | | `DisableAutoInternalsVisibleTo` | Disables automatic `InternalsVisibleTo` generation. | | `AutoIncludeUsings` | Controls SDK-added global usings. | @@ -561,45 +655,49 @@ Read the exported property rather than re-deriving SDK behaviour — it is the S | `IsWebProject` | Marker used in SDK web-project behaviour. | | `IsWebSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | -| `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host`. | +| `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | | `IsBlazorWebAssemblyProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | | `IsRazorClassLibraryProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | | `IsBlazorHostProject` | True for a web SDK project that references a Blazor WebAssembly client. | | `IsBffProject` | True for a web SDK project named `*Bff`, or that sets `IsBffProject=true`. | | `PurviewBrowserSafe` | When `true` on a class library, applies the browser-safe defaults. | -| `PurviewBrowserSafeWarningsAsErrors` | When `false`, browser-safe trim and AOT diagnostics stay warnings. | +| `PurviewBrowserSafeWarningsAsErrors` | When `false`, browser-safe trim and AOT diagnostics fall back to the project's own warning policy (`TreatWarningsAsErrors`, `true` by default). | | `DisablePurviewBrowserSafeReferenceCheck` | Turns off `PRBLZ0001`. | | `PurviewBrowserSafeReferenceCheckAsError` | Reports `PRBLZ0001` as an error. | | `DisablePurviewBlazorWebAssemblyDefaults` | Keeps the generic defaults on a WebAssembly client. | | `PurviewAutoBlazorClientReference` | When `false`, a host does not reference its `.Client` automatically. | | `PurviewBffExposesApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | +| `PurviewAutoServerSharedReference` | When false, a server-side project does not automatically reference a sibling ServerShared project. Client-side projects never get one. Defaults to true. | +| `DisablePurviewServerSharedReferenceCheck` | When true, a client-side or Shared project does not report a ServerShared project reference (PRSHR0001/PRSHR0002). | +| `PurviewServerSharedReferenceCheckAsError` | When true, PRSHR0001/PRSHR0002 are errors instead of warnings. Defaults to false. | +| `MemorySafetyRules` | The C# 15 memory-safety tier: 0 off (default), 1 preview syntax, 2 updated rules. The updated rules are an experimental compiler feature, so they are opt-in. AllowUnsafeBlocks is left unset. | +| `PurviewRuntimeAsync` | When true, a net11.0+ project compiles with the runtime-async compiler feature. Defaults to false; UseRuntimeAsync=true also opts in. | +| `PurviewMultiThreadedBuild` | When true (default), boots a Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). Left unset, a Blazor WebAssembly client writes -mt:false instead. | | `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` that is injected into `@(EditorConfigFiles)`. | | `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapEditorConfigToRepoRoot` | When `true` (default), copies the SDK `.editorconfig` to `RepositoryEditorConfigFilePath` if missing. | | `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | +| `RepositoryDirectoryBuildRspFilePath` | Destination path for bootstrapping a repository-level Directory.Build.rsp when missing. | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | When true, bootstraps a managed Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). | +| `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`, `Directory.Build.rsp`). | | `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, falling back to the version stamped in at pack time). | -| `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | -| `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | -| `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | -| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | Base delay between bootstrap write attempts (default `500`). | -| `PurviewRepoBootstrapCopyFailureAsError` | When `true` (default), a bootstrap write that still fails after every retry is an error. | -| `PurviewSuppressCopyRetryWarnings` | When `true` (default), built-in copy retry notices (`MSB3026`) are demoted to messages. | -| `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. | -| `PurviewSkillsLockFile` | Where the skills CLI's `skills-lock.json` is read from (default `/skills-lock.json`). | -| `PurviewSkillsSourceFolder` | Repository folder holding authored agent skills (default `/skills`). | -| `PurviewSkillsAgents` | Comma-separated agents the skill targets install to (default empty: the CLI auto-detects). | -| `PurviewSkillsDryRun` | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print commands without running them. | -| `PurviewSkillsRunner` | Package runner used to launch the skills CLI (default `bun`; also `npx`, `bunx`, `pnpm`, `yarn`, `deno`, `skills`). | -| `PurviewSkillsRunnerCandidates` | Runners probed on `PATH` after `PurviewSkillsRunner` (default `bun;npx;bunx;pnpm;yarn;deno`). | -| `PurviewSkillsPackage` | Package spec the executed launcher pins to (default `skills@1`). | -| `PurviewSkillsInstallRoot` | Overrides the repository root every skill target runs from. | -| `PurviewRepositoryRootMarkers` | Ordered markers identifying a repository root (default `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json`). | -| `PurviewSkillsCommand` | Overrides the launcher entirely and skips runner detection. | -| `PurviewSkillsSource` | Installs from an explicit git source (`owner/repo`, optionally `#ref`) instead of from referenced packages. | | `CurrentYear` | Current year used in generated assembly metadata. | | `AutoGeneratedAssemblyInfoFile` | File name of the generated AssemblyInfo source file (written under the intermediate `Properties/` folder). | | `AutoGeneratedAssemblyIdentifierFile` | File name of the generated assembly identifier source file (written under the intermediate `Properties/` folder). | +| `AssemblyIdentifierClassName` | Name of the generated assembly identifier marker class. | +| `IsCLIProject` | True when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart` or `QuickStarts`. | +| `IsSharedProject` | True when the project is a shared project. | +| `IsServerSharedProject` | True for ServerShared helper projects (ServerSharedFramework, etc.), which are referenced automatically only by projects that cannot run in a browser. | +| `IsClientSideProject` | True when the project's assemblies are downloaded to a browser: a WebAssembly client, a PurviewBrowserSafe library, a Razor class library, or a project named with a client-side suffix (*.Client, *.ClientShared, *.Browser, *.Wasm, *.WebAssembly, *.Components, or exactly ClientShared or Components). Set explicitly to override. | +| `LangVersion` | C# language version used to build the Roslyn component. Defaults to latest. | +| `Nullable` | Nullable reference-types context used to build the Roslyn component. Defaults to enable. | +| `TreatWarningsAsErrors` | Treats all compiler, analyzer and NuGet warnings as errors. Defaults to true for every project; set false to opt out. | +| `EnforceExtendedAnalyzerRules` | Enforces Roslyn analyzer best-practice rules. Defaults to true for Roslyn components. | +| `Deterministic` | Produce deterministic builds. Defaults to true. | +| `ContinuousIntegrationBuild` | True when building in a recognized CI environment, producing deterministic SourceLink output. | +| `EmbedUntrackedSources` | Embeds untracked source files for SourceLink. Defaults to true for Roslyn components. | +| `PurviewSourceGeneratorFrameworkAnalyzerValidation` | False to opt out of PRSGD0005 self-contained analyzer-closure validation. | #### Example: switch a repo to Xunit + NSubstitute and disable Bogus @@ -618,6 +716,28 @@ Read the exported property rather than re-deriving SDK behaviour — it is the S --- +## MSBuild property schema and IntelliSense + +Every property in the [Configuration reference](#configuration-reference) is described in +`Sdk.props.buildschema.json`, an [MSBuild Editor](https://marketplace.visualstudio.com/items?itemName=mhutch.msbuildeditor) +sidecar packed beside `Sdk.props`. Install the extension in Visual Studio and a project file that +imports the SDK gets completion, descriptions, defaults and — for `TestingFramework`, +`PurviewTargetFrameworkSet` and the other enumerations — the allowed values. Without the extension the +file is inert. + +The schema is the canonical description, and `just properties-check` holds the code, the +compiler-visible list and these tables to it: a property with no entry, a default that differs from the +evaluated one, a missing table row or a schema that fails the MSBuild Editor meta-schema each fail a +named test that says what to add and where. + +A package that ships its own `.props`/`.targets` can check its own sidecar with the opt-in +`ValidatePurviewPropertySchema` target (`EnablePurviewPropertySchemaValidation=true`; `PRPSC0001`–`PRPSC0004`). +A sidecar must be named after the **full** file it describes — `build/Acme.Kit.props.buildschema.json` +beside `build/Acme.Kit.props`. See [Property Schema](docs/wiki/configuration/Property-Schema.md) and the +[`purview-buildsdk-msbuild-properties`](skills/purview-buildsdk-msbuild-properties/SKILL.md) skill. + +--- + ## .NET 11 features .NET 11 is the baseline this SDK targets. The switches below are applied only to a `net11.0` (or @@ -628,7 +748,7 @@ later) target, so the `net10.0` leg of the default `net10.0;net11.0` set is unaf | Runtime Async V2 (opt-in) | `runtime-async=on` when enabled | Enables Runtime Async for a target where the team has decided it is wanted: cleaner live stack traces, lower await overhead, and continuations that skip `ExecutionContext` when nothing is ambient. It is **off by default** because it changes how every `await` compiles; opt in with `PurviewRuntimeAsync=true` or `UseRuntimeAsync=true`. An explicit `false` under either name keeps it off. | | C# 15 updated memory-safety rules (opt-in) | `updated-memory-safety-rules` when enabled | `unsafe` marks requires-unsafe APIs and the compiler records the choice in `MemorySafetyRulesAttribute`. **Off by default** because the updated model is an experimental compiler feature; opt in with `MemorySafetyRules=2`. Independent of `LangVersion`, so the default `latest` is enough. | | Unsafe code | **not** enabled | `AllowUnsafeBlocks` stays at the compiler default (`false`). The updated model is strictest with unsafe code disallowed; a project that needs pointers opts in itself. | -| Multithreaded MSBuild | `Directory.Build.rsp` with `-mt` | Runs a build's projects concurrently in one process, which is 20–26% faster on large solutions per the MSBuild measurements. `-mt` is a host switch, so it cannot be set from a project; the response file is the only mechanism. `-nr:false` accompanies it because a node left by a non-`-mt` build can cache an `RoslynCodeTaskFactory` task with no assembly path and fail the next `-mt` build (`dotnet/msbuild#15213`); the short-lived server still gives Server GC. | +| Multithreaded MSBuild | `Directory.Build.rsp` with `-mt` | Runs a build's projects concurrently in one process, which is 20–26% faster on large solutions per the MSBuild measurements. `-mt` is a host switch, so it cannot be set from a project; the response file is the only mechanism. `-nr:false` accompanies it because a node left by a non-`-mt` build can cache an `RoslynCodeTaskFactory` task with no assembly path and fail the next `-mt` build (`dotnet/msbuild#15213`); the short-lived server still gives Server GC. A repository with a Blazor WebAssembly client gets `-mt:false` instead: a `net11.0` client cannot build under `-mt` (see [WebAssembly client defaults](#webassembly-client-defaults)). | `MemorySafetyRules` tiers: `0` (off, the default), `1` (preview pointer syntax, sets `LangVersion=preview` unless the project named its own version), `2` (updated rules, opt-in). Anything else fails with @@ -665,8 +785,8 @@ exact commit. Test and shared-testing projects are excluded, because their symbo | Property | Default | Description | | -- | -- | -- | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package. Set it **before the SDK import** to match the repository host. | -| `DisableSourceLink` | `false` | Set to `true` **before the import** to stop the SDK adding the package at all. | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package. Set it in `Directory.Build.props` to match the repository host. | +| `DisableSourceLink` | `false` | Set to `true` to stop the SDK adding the package at all. | Pick the provider for your host: @@ -689,7 +809,7 @@ Central Package Management is on, so the chosen provider also needs a `PackageVe `Directory.Packages.props` — the shipped template declares GitHub, Azure Repos and GitLab. `ContinuousIntegrationBuild` is set from the CI environment (`CI`, `GITHUB_ACTIONS`, `TF_BUILD`), never from a local build. Full detail, including on-premises host configuration and self-hosted providers, is -in [SourceLink](docs/wiki/SourceLink.md). +in [SourceLink](docs/wiki/packaging/SourceLink.md). ## ServerShared @@ -709,7 +829,7 @@ automatic `ServerShared` reference is skipped for anything that might be downloa | `Microsoft.NET.Sdk.BlazorWebAssembly` | the client itself is downloaded | | `PurviewBrowserSafe=true` | by definition downloaded | | `Microsoft.NET.Sdk.Razor` | a WebAssembly client can reference a Razor class library, and the build cannot know in advance whether it will | -| `*.Client`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, `*.Components`, `Components` | the shape a Blazor Web App puts on the client | +| `*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, `*.Components`, `ClientShared`, `Components` | the shape a Blazor Web App puts on the client | | `Shared` and `SharedTesting` | a `Shared` project is itself referenced widely, so inheriting the reference would defeat the guarantee | `IsClientSideProject=true`/`false` overrides the detection either way, including restoring the @@ -757,6 +877,22 @@ opts out). A host referencing a client under another name is resolved during the | no container defaults, even with a `Dockerfile` | `PublishAot` on a client is a publish that fails | | no automatic `../Shared*` or `../ServerShared*` reference | a server-side library joining the browser download because of a folder name | | `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe`, and `PRSHR0001` for a `ServerShared` reference | a library joining the download without anyone deciding it was safe there | +| the managed `Directory.Build.rsp` written as `-mt:false`, naming the client | the WebAssembly SDK's `ReadWasmProps` failing under `-mt` with `MSB4060`, which names nothing that leads back to the response file | +| `PRBLZ0002` when a `net11.0`+ client is built under `-mt` anyway, before `PrepareForBuild` | the same failure, when `-mt` came from the command line, a response file you own, or `PurviewMultiThreadedBuild=true` | + +**The multithreaded build.** A `net11.0` WebAssembly client cannot build under MSBuild's `-mt`: the +WebAssembly SDK's `ReadWasmProps` task comes from a custom task factory that cannot run in the +out-of-process task host `-mt` moves it to. The `Directory.Build.rsp` is per repository, so a client +decides for it: when `PurviewMultiThreadedBuild` is unset, a client writes `-mt:false` and a line naming +itself, and every other project leaves that file alone while the named client's project file exists +(and restores `-mt` once it is gone). A `PurviewMultiThreadedBuild` you set always wins, and a file +without the marker line is yours and is never rewritten. A build that is multithreaded anyway stops with +`PRBLZ0002`, which says whether `-mt` came from the command line or a response file and how to turn it +off (`false`, or `-mt:false`). A repository +upgrading from 2.0.x has a managed `-mt` file the first build cannot escape: that build rewrites it, +stops with `PRBLZ0002` saying so, and the next one builds. MSBuild 18 has no property that says `-mt` is +on, so the check looks at where an inline task runs: `-mt` always puts it in the out-of-process task +host. A `net10.0` client builds under `-mt`, so the check does not apply to it. Globalisation, trimming mode and the Blazor runtime settings are left to the Blazor SDK. `DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK import) restores the generic @@ -776,7 +912,7 @@ on a class library turns those into build failures, applying only what the proje `System.Diagnostics.Process`; - `WarningsAsErrors` for each trim, single-file, AOT and `CA1416` id (`PurviewBrowserSafeWarningCodes`), rather than `TreatWarningsAsErrors`, so the rest of the project's warning policy is untouched. - `PurviewBrowserSafeWarningsAsErrors=false` opts out; `CA1416` then falls back to the repository's + `PurviewBrowserSafeWarningsAsErrors=false` opts out, handing the ids back to `TreatWarningsAsErrors` (`true` by default); `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`). File and network I/O are not reported - `File` works against the browser's in-memory file system and @@ -816,7 +952,7 @@ MyProject.IntegrationTests→ IsTestProject=true, TestingType=Integration MyProject.E2ETests → IsTestProject=true, TestingType=E2E ``` -Any suffix from the full list is recognised: `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, `Contract`. +Detection is by shape, not by list: any project name ending in `.Tests` or `.Test` is a test project with `TestingType=` — `Billing.ReplayTests` is `TestingType=Replay`. A bare `.Tests` suffix is not (it has no ``). The `TestType` items in `Sdk.props` are the types `InternalsVisibleTo` generates friend names for (see [InternalsVisibleTo](#internalsvisibleto)): `Acceptance`, `Accessibility`, `Approval`, `Architecture`, `Benchmark`, `BlackBox`, `Capacity`, `Chaos`, `Compatibility`, `Compliance`, `Component`, `Concurrency`, `Configuration`, `Contract`, `Deployment`, `E2E`, `EndToEnd`, `Endurance`, `Environment`, `Failover`, `Functional`, `Fuzz`, `Globalization`, `GrayBox`, `GreyBox`, `Integration`, `Interactive`, `Interoperability`, `Load`, `Localization`, `Migration`, `Mutation`, `Penetration`, `Performance`, `Property`, `Recovery`, `Regression`, `Resilience`, `Sanity`, `Scenario`, `Security`, `Smoke`, `Snapshot`, `Soak`, `Spike`, `Stress`, `System`, `Threat`, `Unit`, `Upgrade`, `Usability`, `Visual`, `WhiteBox`. ### Shared testing projects @@ -834,7 +970,7 @@ The SDK automatically generates `[assembly: InternalsVisibleTo("…")]` attribut Two categories of friend assemblies are generated: -1. **TestType variants** — for each defined `TestType` (`Unit`, `Integration`, `Architecture`, `Contract`, `Functional`, …) the SDK emits the `$(AssemblyName).{TestType}Tests` alias plus the `$(TargetProjectName).{TestType}Tests` and `$(MSBuildProjectName).{TestType}Tests` equivalents, so the attribute resolves regardless of whether naming comes from an explicit `AssemblyName`, the SDK default, or the raw project name. +1. **TestType variants** — for each defined `TestType` (`Unit`, `Integration`, `Architecture`, `Contract`, `Functional`, …) the SDK emits the `$(AssemblyName).{TestType}Tests` alias plus the `$(TargetProjectName).{TestType}Tests` and `$(MSBuildProjectName).{TestType}Tests` equivalents — each only when it is non-empty and differs from the ones before, so no grant is duplicated — and the attribute resolves regardless of whether naming comes from an explicit `AssemblyName`, the SDK default, or the raw project name. 2. **SharedTesting projects** — one per known shared testing project name (`SharedTestingFramework`, `SharedTestingInfrastructure`, etc.). By default (`EnableAssemblyNameGeneration=true`) with a `NamespacePrefix` set, these are prefixed (e.g. `Acme.SharedTestingFramework`); with `EnableAssemblyNameGeneration=false` the raw name is used. ### Disabling automatic InternalsVisibleTo @@ -893,7 +1029,7 @@ public static partial class BillingAssembly var assembly = typeof(BillingAssembly).Assembly; ``` -The class name comes from `$(MSBuildProjectName)` with the `NamespacePrefix` and every remaining dot removed, suffixed with `Assembly` — `Acme.Billing.Core.Abstractions` and `Core.Abstractions` both produce `CoreAbstractionsAssembly`. Set `AssemblyIdentifierClassName` to choose a different name. +The class name comes from `$(MSBuildProjectName)` with a leading `NamespacePrefix` and every remaining non-identifier character removed, suffixed with `Assembly` — with `NamespacePrefix` set to `Acme.Billing`, `Acme.Billing.Core.Abstractions` and `Core.Abstractions` both produce `CoreAbstractionsAssembly`. Set `AssemblyIdentifierClassName` to choose a different name. It is emitted in the global namespace deliberately: a test project that references several assemblies should not need a `using` per marker. Being `partial`, a project can add members to it. @@ -906,7 +1042,7 @@ Test and shared-testing projects do not get one — the test assembly is the one Certain suffixes are automatically stripped from `RootNamespace` to avoid awkward namespace names like `Acme.MyProject.Core.Something`. The current built-in stripping set is intentionally broader than the common examples and includes: -`ServiceDefaults`, `Infrastructure`, `Abstractions`, `ClientShared`, `DataAccess`, `Extensions`, `Contracts`, `Framework`, `Utilities`, `Helpers`, `Library`, `Common`, `Shared`, `Infra`, `Utils`, `Core`, `Host`, `Util`, `Lib`, `EF`, plus the known shared and shared-testing project names (`Shared*`, `SharedTesting*`). +`ServiceDefaults`, `Infrastructure`, `Abstractions`, `ClientShared`, `DataAccess`, `Extensions`, `Contracts`, `Framework`, `Utilities`, `Helpers`, `Library`, `Common`, `Shared`, `Infra`, `Utils`, `Core`, `Host`, `Util`, `Lib`, `EF`, plus the known shared, server-shared and shared-testing project names (`Shared*`, `ServerShared*`, `SharedTesting*`). Three groups are deliberately **not** stripped: @@ -924,18 +1060,18 @@ the items are declared there and only exist to be removed once the import has ev - - - - + + + ``` -This is useful for analyser/source-generator repos that deliberately name projects after the role — for -example `Purview.ZodSharp.SourceGenerators`, `Purview.ZodSharp.CodeFixers`, and `Purview.ZodSharp.CodeFixes` — -and want those roles to survive in the namespace rather than collapse into the parent `Purview.ZodSharp` -namespace. Only suffixes -present in the built-in list need removing; a suffix the SDK does not ship is already kept as-is. +Only suffixes present in the built-in list need removing; a suffix the SDK does not ship — the Roslyn +component names above among them — is already kept as-is. The items drive the build-time pass only: +`Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults` and the shared, server-shared and +shared-testing project names are also stripped while `Sdk.props` evaluates, from a fixed list, so +removing one of those items does not keep it in the evaluated `RootNamespace` (or in the `AssemblyName` +derived from it). See [Assembly Name Generation](docs/wiki/concepts/Assembly-Name-Generation.md#namespace-stripping). ### Extensions namespace rule (`PDS0002`) @@ -955,8 +1091,9 @@ Examples: | `Extensions/TopLevel.cs` | *(global namespace)* | To avoid conflicting guidance, `IDE0130` is suppressed for files in this root `Extensions/` scope. -The shipped `.editorconfig` also suppresses the namespace-conflict diagnostics this convention can -trigger (`CA1724`, `CS0436`, `CS1591`, `IDE0005`), so extension files never need `#pragma` suppressions. +The shipped `.editorconfig` also turns off the namespace-conflict diagnostics this convention can +trigger (`CA1724`, `CS0436`, `CS1591`, `IDE0005`) for files under any `Extensions/` folder +(`[**/Extensions/**]`), so extension files never need `#pragma` suppressions. Outside this scope, normal `IDE0130` behaviour remains unchanged. A code refactoring (`Split extensions class into one class per receiver type`) is offered when a @@ -1054,8 +1191,7 @@ analyzer rules only. A union is a value type, so `CA1815` ("override `Equals` and the equality operators on value types") fires on every one. The members it asks for cannot sensibly be written: C# does not synthesise value equality for a union declaration, and a union is read by matching its active case rather than by comparing two unions. -Analyser support for unions is planned for .NET 12, so until then every union declaration would otherwise -need a `#pragma warning disable CA1815` or a `[SuppressMessage]` beside it. +Without the suppression every union declaration would need a `#pragma warning disable CA1815` or a `[SuppressMessage]` beside it. Both spellings are recognised, because both produce a union and both are reported by `CA1815`: @@ -1112,10 +1248,10 @@ public static class StringExtensions Add `PDS0007` to `NoWarn` to turn the suppression off, which restores `CA1034` for every extension block. -`PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, -`Dns`, `Http`, `Xml`, `DbContext`, ...) while still enforcing uppercase for acronyms such as `Api` → `API`, -`Ai` → `AI`, `Cpu` → `CPU`, `Gpu` → `GPU`, `Cli` → `CLI`, `Gui` → `GUI`, `Ram` → `RAM`, and -`Ssh` → `SSH`. By default the acronym-like segments `Http`, `Xml`, `Json`, `Id`, `Sdk`, `Sql`, `Uuid`, +`PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Uuid`, `Url`, `Dns`, +`Http`, `Xml`, `DbContext`, ...) while still enforcing uppercase for the acronyms `Api` → `API`, +`Ai` → `AI`, `Ui` → `UI`, `Io` → `IO`, `Os` → `OS`, `Cpu` → `CPU`, `Gpu` → `GPU`, `Cli` → `CLI`, +`Gui` → `GUI`, `Ram` → `RAM`, `Ssh` → `SSH` and `Dsl` → `DSL`. By default the acronym-like segments `Http`, `Xml`, `Json`, `Id`, `Sdk`, `Sql`, `Uuid`, `Url`, `Dns`, `Tcp`, `Udp`, `Csv`, `Pdf`, `Html`, `Css`, `Ftp`, `Smtp`, `Imap`, and `Db` are exempt — `Db` is deliberately exempt so the prevalent EF Core/ADO.NET spellings (`DbContext`, `DbConnection`, `DbSet`, `CreateDbContext`) are never flagged; a repo that prefers `DB` can re-enable it via @@ -1159,7 +1295,7 @@ With the default enabled: | `ServiceDefaults` | `Acme` | `Acme` | `Acme.ServiceDefaults` (full logical name, so it stays distinct) | | `Acme` | `Acme` | `Acme` | `Acme` | -Explicitly setting `` before the SDK import always wins: `AssemblyName`/`PackageId` follow that override instead of re-appending a stripped suffix. +A `` set before the SDK import replaces the logical project name, and `AssemblyName`/`PackageId` follow it instead of re-appending a stripped suffix — but it is still stripped by both passes. Only a `` declared in the project file itself is left exactly as written. The table shows the evaluated `RootNamespace`; the build strips the wider list as well, so `Core.Infrastructure` compiles as `Acme` (see [Assembly Name Generation](docs/wiki/concepts/Assembly-Name-Generation.md)). Test projects keep their detected suffix: `Api.UnitTests` → `AssemblyName`/`PackageId` = `Acme.Api.UnitTests`, while `RootNamespace` remains `Acme.Api`. @@ -1171,7 +1307,7 @@ Explicit `` or `` in a `.csproj` (or `Directory.Build.p ## Central Package Management -The SDK sets `ManagePackageVersionsCentrally=true`. The `templates/Directory.Packages.props` file contains `PackageVersion` entries for all packages the SDK auto-adds — all set to `Version="*"` (floating to latest). +The SDK sets `ManagePackageVersionsCentrally=true`. The `templates/Directory.Packages.props` file contains a pinned `PackageVersion` for every package the SDK auto-adds, grouped under version properties (`TUnitVersion`, `AspireVersion`, …). A `PackageVersion` is a minimum, not an exact pin. To pin a package: @@ -1192,7 +1328,7 @@ To add project-specific packages, just append `PackageVersion` entries to your ` ```sh dotnet build src/BuildSdk.slnx -c Release -dotnet test src/BuildSdk.slnx -c Release +dotnet test --solution src/BuildSdk.slnx -c Release dotnet pack src/src/BuildSdk/BuildSdk.csproj -o ./artifacts ``` diff --git a/commitlint.config.mts b/commitlint.config.mts index 1d3240b..29384ce 100644 --- a/commitlint.config.mts +++ b/commitlint.config.mts @@ -1,9 +1,15 @@ +// Conventional Commits, plus `qol` (quality of life) for developer-experience changes: +// tooling, scripts, editor config, DX tweaks that are neither a feature nor a fix. +// Keep the type-enum array inline and comment-free so Show-CommitTypes can parse it. export default { + helpUrl: 'https://www.conventionalcommits.org/en/v1.0.0/', rules: { 'body-leading-blank': [1, 'always'], - 'body-max-line-length': [2, 'always', 100], + // Warn rather than fail: URLs, stack traces and pasted output legitimately run long. + 'body-max-line-length': [1, 'always', 100], 'footer-leading-blank': [1, 'always'], - 'footer-max-line-length': [2, 'always', 100], + // Warn rather than fail: Co-authored-by and issue-link trailers are often long. + 'footer-max-line-length': [1, 'always', 100], 'header-max-length': [2, 'always', 100], 'header-trim': [2, 'always'], 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], @@ -14,7 +20,7 @@ export default { 'type-enum': [ 2, 'always', - ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'], + ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'qol', 'refactor', 'revert', 'style', 'test'], ], }, }; diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md deleted file mode 100644 index b576ee1..0000000 --- a/docs/wiki/Agent-Skills.md +++ /dev/null @@ -1,587 +0,0 @@ -# Agent Skills - -[Agent Skills](https://skills.sh/) are folders of `SKILL.md` guidance that coding agents load on -demand. Purview publishes them in the standard format and installs them with the standard -[`skills` CLI](https://skills.sh/) — `bun x skills add`, `bun x skills update`, `bun x skills remove`. - -`Purview.BuildSdk` deliberately does **not** reimplement any of that. It knows one thing the CLI -cannot know: which skills are relevant to the NuGet packages a project actually references, and at -which version. So the SDK **discovers and recommends**, and the CLI **installs**. - -> The reporting targets recommend commands and never run them. Only `InstallPurviewSkills` and -> `UpdatePurviewSkills`, invoked by name, run the CLI, and only `FixPurviewSkills` deletes anything. -> Nothing on this page installs, updates or removes a skill as a side effect of -> a restore, a build, a design-time build or a test run. - -## The boundary - -```mermaid -flowchart TD - NuGet["Purview NuGet references
(project.assets.json)"] - Metadata["Package-to-skill metadata
(.agents/skills/ + nuspec repository commit)"] - Installed["Local skill state
(skills-lock.json)"] - SDK["Purview Build SDK"] - Decision{"Skill status"} - - Add["Suggest: bun x skills add owner/repo#commit"] - Update["Suggest: pinned re-add
(not a blind bun x skills update)"] - List["Suggest: bun x skills list"] - Restore["Suggest: bun x skills experimental_install"] - Use["Optional: bun x skills use"] - OK["No recommendation"] - - CLI["Standard skills CLI"] - Lock["skills-lock.json"] - Agents["Agent skill directories"] - - NuGet --> SDK - Metadata --> SDK - Installed --> SDK - SDK --> Decision - - Decision -->|Missing| Add - Decision -->|Ref drift| Update - Decision -->|Unknown| List - Decision -->|Lockfile not restored| Restore - Decision -->|Compatible| OK - - Add --> CLI - Update --> CLI - List --> CLI - Restore --> CLI - Use --> CLI - - CLI --> Lock - CLI --> Agents -``` - -| Component | Owns | -| -- | -- | -| Purview libraries | Their API guidance, authored as skills in their own repository | -| Skill publication | Making those skills reachable by `bun x skills add` | -| Package-to-skill metadata | Which package ships which skills, and at which commit | -| Purview Build SDK | Reading package references, evaluating local state, recommending commands | -| Skills CLI | Installing, updating, removing, source tracking, agent-specific destinations | -| Consuming repository | Its installed skills and its `skills-lock.json` | - -## Publication - -Skills are authored at the **repository root**, in `skills//SKILL.md`, and published through -the skills CLI, which clones the git repository and discovers a root `skills/` folder. - -That is the only route. No NuGet package carries skills: a package holds .NET artifacts, and the -CLI delivers skills from git. - -```bash -bun x skills add purview-dev/build-sdk # every skill -bun x skills add purview-dev/build-sdk --skill purview-buildsdk-testing -bun x skills add purview-dev/build-sdk --list # see what is there, install nothing -``` - -Skills live **alongside the library they describe**, in that library's repository, rather than in -one central skills repository. A central repository would have to re-derive which skill belongs to -which library, and would drift from the libraries it documents. - -## Authoring - -`bun x skills init ` scaffolds a skill; move it under `skills//` and adapt the frontmatter -to Purview conventions. `ValidatePurviewSkills` runs `BeforeTargets="Pack"`, so a malformed skill -fails the build of the repository that owns it rather than reaching the CLI, which would silently -skip it. It checks that: - -- every folder under the skills root has a `SKILL.md`, -- that file opens with YAML frontmatter carrying `name` and `description`, -- `name` matches the folder name, because the CLI installs by frontmatter name while a human reads - the folder name, and -- every `references/...` path the body links to exists. - -Failures are reported as `PRSKL0005`. Prose quality is deliberately not checked: a build can only -check that a skill is well formed. - -Keep shared engineering guidance in one skill and link to it, rather than copying it into every -library's skill. - -## How the SDK maps a package to its skills - -There is **no custom `skills.json` manifest**. A package that ships skills already carries both -halves of the mapping, and the SDK reads them: - -| Half | Source | -| -- | -- | -| Which skills this package ships | `.agents/skills//` inside the restored package | -| Which git repository and commit built them | the nuspec's ``, written by SourceLink | - -Both are local to the restored package, so resolution is offline and triggers no restore of its own. -A package whose nuspec has no GitHub repository URL, or no commit, **cannot** be pinned; it is -reported rather than installed from a guessed default branch. - -## Recommendations - -Recommendations read the restore graph and the CLI's own `skills-lock.json`, both local; they -recommend commands and never run them; and `RecommendPurviewSkills` never fails a build. They run -only when a target is named — never during an ordinary build (see [When it runs](#when-it-runs)). - -```bash -dotnet msbuild -t:RecommendPurviewSkills # explicit, always reports -``` - -Every skill a referenced package ships is classified: - -| State | Diagnostic | Severity | Recommendation | -| -- | -- | -- | -- | -| Installed at the referenced commit | *(none)* | — | — | -| Not installed | `PRSKL0010` | Warning | `bun x skills add "#" --skill …` | -| Installed from a different ref | `PRSKL0011` | Warning | pinned re-add at the referenced commit | -| Compatibility unverifiable | `PRSKL0012` | Message | `bun x skills list` | -| From a repository no longer referenced | `PRSKL0013` | Message | `bun x skills remove ""` | -| Lockfile present, nothing restored | `PRSKL0014` | Message | `bun x skills experimental_install` | - -Severity splits on what a developer can act on. A missing or drifted skill is one command away, so -it is a warning. An unverifiable one usually is not — a package without SourceLink metadata cannot -be pinned by anybody — so it stays a message and can never break a build that treats warnings as -errors. Warnings are suppressible in the usual way: - -```xml - - $(MSBuildWarningsAsMessages);PRSKL0010;PRSKL0011 - -``` - -**Unverifiable** (`PRSKL0012`) covers three distinct cases, all of which mean "there is nothing -reliable to compare against": the package cannot be pinned at all; the skill was installed without a -ref; or a skill of the same name is installed from some other repository. The last is never treated -as drift and never overwritten — somebody installed it deliberately. - -**Orphan detection** (`PRSKL0013`) is **owner-scoped on purpose**. A lockfile cannot record who -asked for an entry, so only an entry under an owner the project *does* reference, but from a -repository it *no longer* references, is reported. A skill from any other owner is the developer's -own and is never mentioned. This is a heuristic, which is why it is a message and why nothing is -ever removed automatically. - -### When it runs - -**Never during a build.** There is no `AfterTargets="Build"` hook: reading the restore graph and -the lockfile for every project on every build was measured as the largest avoidable cost in an -otherwise up-to-date build, and its only output was advice. Each target below is reached only -because somebody named it. - -| Target | Behaviour | -| -- | -- | -| `RecommendPurviewSkills` | Reports the state. Never fails a build. | -| `VerifyPurviewSkills` | The same report, escalating `PRSKL0010`/`PRSKL0011` to an error. The CI gate. | -| `InstallPurviewSkills` | Installs every skill the restored packages ship, pinned to each package's commit, via the skills CLI. | -| `UpdatePurviewSkills` | Re-adds every source in `skills-lock.json` — package skills at their package's commit — refreshing installed skills and adding new ones. See [Updating](#updating). | -| `CheckPurviewSkills` | Fails when an agent skill directory exists for an agent `PurviewSkillsAgents` does not name. See [Choosing agents](#choosing-agents). | -| `FixPurviewSkills` | Removes the CLI's links from those directories, keeping and reporting anything real. | -| `ListPurviewSkills` | Lists what the restored packages ship. Installs nothing. | - -A correctly installed set produces no output at all from either reporting target. - -### Configuration - -| Property | Default | Description | -| -- | -- | -- | -| `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the CLI's lockfile is read from. | -| `PurviewSkillsAgents` | *(empty — the CLI auto-detects)* | Agents the skill targets install to and recommended commands name; when set, the only agent skill directories `CheckPurviewSkills` allows. | -| `PurviewSkillsDryRun` | `false` | Print the install and update commands without running them. | -| `PurviewSkillsRunner` | `bun` | Package runner used to launch the CLI. | -| `PurviewSkillsRunnerCandidates` | `bun;npx;bunx;pnpm;yarn;deno` | Probed in order after `PurviewSkillsRunner`. | -| `PurviewSkillsPackage` | `skills@1` | Package spec the executed launcher pins to. | -| `PurviewSkillsInstallRoot` | *(resolved)* | Overrides the repository root every skill target runs from. | -| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers that identify a repository root; the first is definitive. | - -Removed in this release, with no replacement needed: `EnablePurviewSkillRecommendations`, -`PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath`. They existed to gate and -deduplicate a build hook that no longer exists. - -## Version compatibility - -NuGet package versions and skill versions evolve independently, so a project on an older library -must not be advised to install skills that describe a newer API. - -The mechanism is a **commit pin**, and it is exact by construction. The nuspec of the *restored -package version* names the commit it was built from, and the CLI accepts `owner/repo#` and -records that ref in `skills-lock.json`. Comparing the two is the whole compatibility check: - -```text -package Purview.Example 1.2.3 → nuspec commit 0123456… -skills-lock.json: purview-alpha → ref aaaaaaa… ← drift -``` - -This is why **`bun x skills update` is not recommended on drift**. `update` installs the newest -published skill, which is correct only when the newest skill happens to describe the referenced -package version — something that cannot be known offline, and that is false for any project not on -the latest package. `PRSKL0011` therefore names the referenced package's own commit instead: - -```bash -bun x skills add "purview-dev/purview-example#0123456789abcdef0123456789abcdef01234567" \ - --skill "purview-alpha" -y -``` - -A re-add at latest is only right for skills no package version speaks for. `UpdatePurviewSkills` -re-adds those at latest and a package's skills at that package's commit, and never runs -`skills update`. - -> Note the repeated `--skill` flag. The CLI does not split a comma-separated value: given one, it -> matches nothing and reports "No matching skills found". - -## The lockfile - -`skills-lock.json` is the CLI's own record of what is installed and where it came from. The SDK -reads it and introduces **no competing lockfile**. Reading it rather than parsing CLI output is -deliberate: the JSON listing (`bun x skills list --json`) is machine-readable but omits the `ref`, and -the ref is the entire question. The lockfile carries it, and reading a file costs no Node tooling — -which is what keeps recommendations working on a machine that can build .NET but has no Node. - -A lockfile is a record of an install, not proof of one: a fresh clone carries a committed -`skills-lock.json` and no installed skills at all. The SDK detects that case — a lockfile with no -agent skill directory anywhere at the repository root — and reports `PRSKL0014`: - -```bash -bun x skills experimental_install # restore everything in skills-lock.json -``` - -This command is **experimental**, so it is suggested and never required, never invoked -automatically, and always offered with the supported fallback (`bun x skills add`). Prefer a stable -restore command if the CLI gains one. The detection looks for any root dot-folder containing a -`skills` folder rather than hard-coding agent directories, which belong to the CLI and change -independently of this SDK. '.agents/' is included in that scan: it is the CLI's universal install location, and nothing else writes there any more. - -## Installing from the SDK - -Installation is **explicit**. None of these targets has `BeforeTargets`/`AfterTargets`: - -```bash -dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing -dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI -dotnet msbuild -t:UpdatePurviewSkills # re-add every lockfile source: refresh, and add new skills -dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist -dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory -``` - -`-p:PurviewSkillsDryRun=true` makes install and update print every command and run none. - -They run **only when named**. Nothing in the SDK hooks or depends on them, and install, update and fix -refuse to run when pulled into a build, a restore or a design-time build (`PRSKL0009`) — a -consumer's own `AfterTargets="Build"` hook included. Name the solution: each target runs once -however many projects it holds, because every project hands the work to the same elected leader -project and MSBuild's result cache runs it once. Package resolution reads every project in the -solution, so a package only a test project references is still found; a single project sees only its -own packages. For `just` recipes, see the -[`purview-buildsdk-skills-justfile` skill](../../skills/purview-buildsdk-skills-justfile/SKILL.md). - -`InstallPurviewSkills` shells out to the CLI rather than reimplementing it, because the CLI owns the -agent-specific destinations and their discovery conventions — which differ per agent and change -independently of this SDK — and owns `skills-lock.json`. - -`InstallPurviewSkills` installs every skill the restored packages ship, not only the missing ones. -Re-running it is safe: the CLI reinstalls each skill it is given, replacing the installed copy. - -### Updating - -```bash -dotnet restore # after bumping a PackageReference -dotnet msbuild -t:RecommendPurviewSkills # optional: shows PRSKL0011 for what will change -dotnet msbuild -t:UpdatePurviewSkills -``` - -`UpdatePurviewSkills` reads `skills-lock.json` and re-adds each unique source once. It updates with -`skills add` rather than `skills update`, so a skill a source has gained since the last install is -added as well as the installed ones refreshed: - -| Source | Re-added as | -| -- | -- | -| shipped by a restored package | `#`, naming every skill that package version ships — the commit it was built from, the same pinned add `PRSKL0011` recommends | -| anything else | ` --skill "*"` — its latest, with every skill it has | - -That is the whole update. `skills add` reinstalls every skill it selects, so one re-add refreshes the -installed skills and adds the new ones; `skills update` would only re-fetch what the lockfile -already lists, and is never run. Every source is attempted even when one fails, and each failure is -named as `PRSKL0008`. No lockfile is `PRSKL0007` — -install first. A CI job can then call `-t:VerifyPurviewSkills` to confirm nothing is left drifted. - -`InstallPurviewSkills` is also enough to move package skills to a new package version: it re-adds -each at its package's commit. - -### Choosing agents - -`PurviewSkillsAgents` is **empty by default**: no `--agent` is passed and the CLI auto-detects the -agents installed on the machine (installing to all of them if it detects none). Set it to choose. -Each agent becomes its own `--agent` flag, because the CLI does not split a comma-separated value. - -```xml - - universal - -``` - -On a command line, `dotnet msbuild -p:` itself splits on `,` and `;`, so escape the separator -(`-p:PurviewSkillsAgents=claude-code%2Ccodex`) or quote the whole value -(`-p:PurviewSkillsAgents=\"claude-code codex\"`); spaces separate agents too. - -Once agents are named, `CheckPurviewSkills` enforces them: any agent skill directory at the repository -root that belongs to an agent not named fails as `PRSKL0015`. Two directories are always allowed — -`.agents/skills`, the CLI's canonical store that every agent directory links into, and the authored -`skills/` folder, even though openclaw installs into a root `skills/`. With `PurviewSkillsAgents` -empty, every agent directory is a legitimate destination and both targets report that there is -nothing to enforce. - -`FixPurviewSkills` removes what is provably the CLI's: links (symlinks, and junctions on Windows) — -the link only, never its target — and then any folder left empty. A real file is kept and reported, -because it cannot be told from hand-written content; note that for some agent selections the CLI -writes copies rather than links, and those are kept too. - -The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the CLI's -agent table. The CLI adds agents independently of this SDK, so an agent the map does not know is -`PRSKL0016` (a warning), fixed by adding an item: - -```xml - - - -``` - -### Installing from an explicit source - -To install a Purview skill the project has no `PackageReference` for, name the source directly. The -caller owns the ref in that case; append `#` to pin it: - -```bash -dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/engineering-playbook -``` - -### Package runners - -The CLI is published to npm, and every package runner spells "run this npm package" its own way. -`PurviewSkillsRunner` picks one; it defaults to **`bun`** (`bun x skills`). - -| `PurviewSkillsRunner` | Executed | Recommended in diagnostics | -| -- | -- | -- | -| `bun` *(default)* | `bun x skills@1` | `bun x skills …` | -| `npx` | `npx -y skills@1` | `npx skills …` | -| `bunx` | `bunx skills@1` | `bunx skills …` | -| `pnpm` | `pnpm dlx skills@1` | `pnpm dlx skills …` | -| `yarn` | `yarn dlx skills@1` | `yarn dlx skills …` | -| `deno` | `deno run -A npm:skills@1` | `deno run -A npm:skills …` | -| `skills` | `skills` | `skills …` | - -A runner this SDK has never heard of still works: it falls through to ` skills@1`, the form -most new runners adopt. - -```xml - - pnpm - -``` - -`yarn dlx` needs Yarn 2+; Yarn 1 has no `dlx`. The executed launcher pins the CLI to a major so a -breaking release cannot change what `InstallPurviewSkills` does — `PurviewSkillsPackage` (default -`skills@1`) overrides the pin. Recommendation text deliberately drops it: that is a command for a -person to run, and `bun x skills add` is the form they know. - -### Installing in the repository that authors the skills - -A repository that *authors* skills has no `PackageReference` to itself, and pinning to a git commit -would be wrong anyway: an edit to `skills//SKILL.md` would not reach an agent until it was -pushed and reinstalled. Install from the working tree instead: - -```bash -dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=. -``` - -The CLI records that portably — `"source": "."`, `"sourceType": "local"` — so the resulting -`skills-lock.json` survives a clone and can be committed. - -`Purview.BuildSdk` itself deliberately does **not** do this. Every skill it authors describes the -packages it ships and exists for *other* repositories to install, so an agent working in -`build-sdk` reads them at their authored path and `.agents/skills/` is left to the skills CLI (apart -from one repository-local skill). Install from the working tree when a repository authors skills it also consumes; -skip it when, as here, the skills are published rather than used locally. - -What the CLI writes should be gitignored, since all of it is reproducible from `skills/` plus the -lockfile: - -| Path | Written by | Tracked | -| -- | -- | -- | -| `skills//` | authored by hand | yes | -| `skills-lock.json` | the CLI | yes | -| `.agents/skills//` | the CLI's "universal" location | no | -| `.agents/skills//` | a repository-local skill, authored by hand for this repository only | yes | -| `.claude/skills/`, `agent/skills/`, … | per-agent links into it | no | - -> **The CLI's universal location is `.agents/skills/`.** Ignore its contents specifically rather than -> the whole of `.agents/`, so any authored `.agents/agents/` and `.agents/prompts/` content stays -> tracked — and un-ignore a **repository-local** skill by name. That is guidance about working in this -> repository, which must never ship to a consumer, so it cannot live under `skills/`; `.agents/skills/` -> is the one location an agent reads natively. `RepositorySkillsSelfTests.InstalledSkillsAreNotTracked` -> still fails if any skill the CLI *installed* (anything in `skills-lock.json`) is tracked. - -### One installer - -Skills have exactly one installer: **the skills CLI**. The SDK writes nothing into an agent -directory, at all. - -| Source | Destination | Who writes it | -| -- | -- | -- | -| package `.agents/skills/**` | *(not copied)* | — metadata only | -| git `skills//` | `.agents/skills/`, `.claude/skills/`, … | the skills CLI | - -A package ships `.agents/skills//` because `ResolvePurviewSkillSources` reads it to map a -package to its skills and to the commit it was built from. It is not content to be copied into the -consuming repository. - -Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming -repository. That is gone. The CLI's universal install location is `.agents/skills/` — exactly where -the mirror wrote — so the two were two writers on one set of paths: the mirror would silently -overwrite a pinned install on the next build, while `PRSKL0011` compared a lockfile ref against -content the mirror had already replaced. The CLI covers the whole job, so the mirror was removed -rather than fenced off, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` and -the `PurviewAgentFolder*` properties. A repository that wants a package's skills installs them with -the CLI — which is exactly what `InstallPurviewSkills` delegates to. - -### Detecting the CLI - -Installation probes `PATH` rather than assuming the configured runner is present, honouring -`PATHEXT` on Windows where these are `.cmd` shims. The order is: - -1. `skills` — used as-is if it is already on `PATH`. No download, works offline. -2. `$(PurviewSkillsRunner)` — the configured preference. -3. `$(PurviewSkillsRunnerCandidates)` — `bun;npx;bunx;pnpm;yarn;deno` by default. - -So a machine with bun but no Node still installs, rather than reporting that nothing was found. The -runner that is actually found wins over the configured preference, because it is the one that can -really run. `PATH` is read directly rather than by spawning a version check: it is deterministic, -costs no process, and cannot hang on a cold download. - -- Nothing found is `PRSKL0004`, naming the fix rather than surfacing a raw `'bun' is not recognized`. -- `PurviewSkillsCommand` overrides the launcher entirely and skips detection. - -`ListPurviewSkills` and `RecommendPurviewSkills` **never** error on a missing runner. Resolving, -evaluating and reporting need no Node tooling at all. - -### Always from the repository root - -`skills-lock.json` and the agent directories belong to the repository, not to a project, and a -solution has many projects and exactly one of each of those. So every skill target runs the CLI -**from the repository root, always**. The root is resolved by this chain: - -1. `PurviewSkillsInstallRoot` — an explicit override -2. `PurviewSkillsRepoRoot` — when the *user* set it -3. the **marker walk** (below) -4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them -5. `GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY` or `CI_PROJECT_DIR` -6. the git source root SourceLink reports - -There is deliberately **no fall back to the project directory**: that would scatter a lockfile and an -agent directory through the tree, one per project, which is the thing being prevented. If the root -cannot be resolved, `InstallPurviewSkills` reports `PRSKL0003` and names `PurviewSkillsInstallRoot` -rather than guessing; recommendations simply stay silent, because a build never fails over advice. - -#### The marker walk - -`PurviewRepositoryRootMarkers` lists what identifies a repository root, most authoritative first: - -```text -.git/ ; .github/ ; .gitattributes ; LICENSE.md ; AGENTS.md ; .agents/ ; CLAUDE.md ; .claude/ ; package.json -``` - -A trailing slash marks a directory — and still matches a *file* of that name, because `.git` is a -directory in a clone but a file in a worktree or submodule. MSBuild's own -`GetDirectoryNameOfFileAbove` matches files only, which is why this is a task rather than an -intrinsic: it finds nothing for `.git/`, `.github/`, `.agents/` or `.claude/`. - -Resolution runs in two passes, because neither pure priority nor pure proximity is right alone: - -1. **The first marker in the list is definitive** and is searched all the way up. `.git/` *is* the - repository root by definition, so a project inside a monorepo package resolves to the repository - rather than to the nearer folder holding a `package.json`. -2. **Otherwise the nearest ancestor** holding any marker wins, with list order breaking ties inside - that one directory. - -Pass 2 is nearest-first on purpose. Searching every remaining marker to the top would let a stray -`.claude/` in a developer's home directory outrank the `package.json` at the real repository root. -For the same reason the home directory and everything above it are **never** candidates — a -repository root is never your home directory. A repository *inside* your home directory is -unaffected, because it sits below it. - -| Project at `/repo/sub/src/proj` | Resolves to | -| -- | -- | -| `/repo/.git/` + `/repo/sub/package.json` | `/repo` — the definitive marker wins from a distance | -| `/repo/.github/` + `/repo/sub/package.json` | `/repo/sub` — nearest wins once `.git/` is absent | -| `/repo/LICENSE.md` + `/repo/package.json` | `/repo`, reported as `LICENSE.md` (higher in the list) | -| no marker below the home directory | unresolved — `PRSKL0003`, never `~` | - -## One-off use - -To consult a skill once without installing it: - -```bash -bun x skills use purview-dev/build-sdk@purview-buildsdk-testing | claude -``` - -This is a good fit for a one-time operation. It is not a substitute for installing the skills a -project needs throughout development, and the SDK's recommendations do not suggest it in place of -`add`. - -## npm distribution - -`bun x skills experimental_sync` discovers skills shipped inside npm packages, by reading -`node_modules`. - -**Purview does not use it, and should not.** Purview libraries ship as NuGet packages; their skills -are already described by those packages' metadata, and are already installable -from git by `bun x skills add`. Publishing a parallel npm package would mean: - -- a second release pipeline to version, publish and keep in step with the NuGet one, -- a `package.json` and a `node_modules` restore in .NET repositories that otherwise need neither, - purely to obtain documentation, and -- two sources of truth for the same skill, with no mechanism tying the npm version to the NuGet - version — losing exactly the commit-pinned compatibility described above. - -The git source the CLI already supports delivers the same skills with none of that. `experimental_sync` -remains useful to a repository that independently consumes npm packages shipping skills; it is simply -not a Purview distribution channel. Revisit only if Purview starts shipping npm packages for their -own sake. - -## Supported CLI commands - -Verified against the `skills` CLI v1. - -| Command | Purpose | Role here | -| -- | -- | -- | -| `bun x skills add ` | Install skills | The installation workflow; accepts `owner/repo#` | -| `bun x skills list` | List installed skills | Diagnostics; `--json` is machine-readable but omits `ref` | -| `bun x skills find [query]` | Search for skills | Discovery fallback where no package mapping exists | -| `bun x skills update` | Update to latest | Correct only when latest matches the referenced package | -| `bun x skills remove` | Remove skills | Developer-managed | -| `bun x skills use @` | Use without installing | One-off guidance | -| `bun x skills experimental_install` | Restore from `skills-lock.json` | Optional bootstrap; experimental | -| `bun x skills init [name]` | Scaffold a skill | Authoring | -| `bun x skills experimental_sync` | Sync from `node_modules` | Not used by Purview — see above | - -## Diagnostics - -| Code | Meaning | -| -- | -- | -| `PRSKL0001` | `project.assets.json` is missing; run `dotnet restore` first | -| `PRSKL0002` | A package ships skills but carries no pinnable git identity | -| `PRSKL0003` | The repository root could not be resolved, so there is nowhere correct to install | -| `PRSKL0004` | No package runner was found on `PATH` | -| `PRSKL0005` | An authored skill is malformed | -| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode`: update no longer runs `skills update` | -| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` to update from | -| `PRSKL0008` | Re-adding a lockfile source failed | -| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | -| `PRSKL0010` | Skills a referenced package ships are not installed | -| `PRSKL0011` | Installed skills are pinned to a different ref than the referenced package | -| `PRSKL0012` | Compatibility cannot be verified | -| `PRSKL0013` | A skill is installed from a repository no longer referenced | -| `PRSKL0014` | `skills-lock.json` exists but no skills have been restored | -| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | -| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | -| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | - -## See also - -- [Configuration Reference](Configuration-Reference.md) -- [Packaging](Packaging.md) diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 9e5da0a..b807eb8 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -1,18 +1,21 @@ # Configuration Reference -Set any of these properties **before** the `` in your `Directory.Build.props` (most are -consumed during `Sdk.props` evaluation). Explicit values set before the import are always preserved. +Setting a property in `Directory.Build.props` **before** the `` always works. A property whose +description says *set it before the `Sdk.props` import* only works there; the rest can also be set in a +project file. Every property here is described in the SDK's [property schema](configuration/Property-Schema.md), so +the same text appears in csproj IntelliSense. ## Required settings and versions -Four values have to be set correctly for the SDK to behave as documented. The first three are read -during `Sdk.props` evaluation, so they must be set **before** the ``. +Four values have to be set correctly for the SDK to behave as documented. `NamespacePrefix`, and +`RootPackageJson` when you name the version file explicitly, are read during `Sdk.props` evaluation, so +they must be set **before** the ``. | Setting | Where | Notes | | -- | -- | -- | | `NamespacePrefix` | `Directory.Build.props` (pre-import) | Required. Without it the build fails (`ValidateRootNamespacePrefixTarget`) unless `DisableNamespacePrefixCheck=true`. | | SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | Pinned to a released version. Bootstrapped when missing; `PurviewBuildSdkVersionForGlobalJson` decides the value written. | -| Product version | repo-root `package.json` → `version` | Applied to `Version` and `PackageVersion` by default; `UsePackageJsonVersion=Strict` fails the build instead of falling back to `0.0.1`. Set `RootPackageJson` (pre-import) to name the file explicitly. | +| Product version | repo-root `package.json` → `version` | Applied to `Version` and `PackageVersion` by default. A git repository with no root `package.json` fails the build: add one, or set `UsePackageJsonVersion=false`. `UsePackageJsonVersion=Strict` also fails when no repository root is found, instead of falling back to `0.0.1`. Set `RootPackageJson` (pre-import) to name the file explicitly. | | Package versions | `Directory.Packages.props` | Central Package Management is on; a `PackageVersion` is a minimum, not an exact pin. | ## Version detection @@ -20,11 +23,12 @@ during `Sdk.props` evaluation, so they must be set **before** the ``. | Property | Default | Description | | -- | -- | -- | | `UsePackageJsonVersion` | `true` | `true` enables version detection, `false` disables it, and `Strict` requires version detection to succeed (build fails if no version source can be resolved). | -| `RootPackageJson` | *(auto-discovered)* | Explicit path to a `package.json`. Relative paths are resolved from the project directory. | +| `RootPackageJson` | *(auto-discovered)* | Explicit path to a `package.json`. A relative path resolves from the project directory, so in `Directory.Build.props` prefer `$(MSBuildThisFileDirectory)package.json`. | | `EnableVersionDetectionCache` | `true` | Enables local caching of auto-discovered package.json version results. | | `VersionDetectionLogEnabled` | `false` | Emits a high-importance message showing the detected package version. | +| `VersionDetectionCacheFile` | *(one file per project directory under the temporary directory)* | File the package.json version detection cache is written to. Set it before the `Sdk.props` import. | -See [Version Detection](Version-Detection.md) for the full resolution rules. +See [Version Detection](configuration/Version-Detection.md) for the full resolution rules. ## General @@ -32,29 +36,32 @@ See [Version Detection](Version-Detection.md) for the full resolution rules. | -- | -- | -- | | `NamespacePrefix` | *(required)* | Root namespace prefix, e.g. `Acme`. Results in `Acme.MyProject`. | | `DisableNamespacePrefixCheck` | `false` | Set to `true` to suppress the build error for missing `NamespacePrefix`. | -| `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop `ValidatePurviewStylePolicy` failing the build (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides `dotnet_style_require_accessibility_modifiers`, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set, `CA1515` for Aspire hosts and CLI apps) are ignored. See [Style policy](Analyzers.md#style-policy). | +| `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop `ValidatePurviewStylePolicy` failing the build (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides `dotnet_style_require_accessibility_modifiers`, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set, `CA1515` for Aspire hosts and CLI apps) are ignored. See [Style policy](analyzers/Analyzers.md#style-policy). | | `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | Production API-surface rules exempted in test and shared-testing projects (they keep the strict style contract). Override before the SDK import to narrow or extend the list. | | `DisablePurviewTestContextRuleSet` | `false` | Set to `true` to make test and shared-testing projects enforce the production API-surface rules as well. | -| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects; `Library` also clears `IsTestProject`/`IsTestingPlatformApplication`. Set to `Exe` before the SDK import to keep the test packages' executable/test-host shape. | +| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects; `Library` also clears `IsTestProject`/`IsTestingPlatformApplication`. Set to `Exe` (in the project or `Directory.Build.props`) to keep the test packages' executable/test-host shape. | | `TargetFramework` | `net10.0;net11.0` | Override the default TFMs per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. To multi-target from a central, overridable definition rather than a hard-coded list, see [Target framework sets](#target-framework-sets). | -| `DisableUnconditionalProjectDeclarationCheck` | `false` | Set to `true` to suppress `PurviewConditionedProjectDeclaration`. The SDK reads `TargetFramework`, `TargetFrameworks`, `IsRoslynComponent`, `IsRoslynComponentOnly`, `IsPackable` and `PurviewTargetFrameworkSet` from the project XML before the project body is evaluated, so it cannot evaluate a `Condition` on them and will not see the declaration — the SDK default applies instead. Declare those properties unconditionally, or set them in a file imported before this SDK. | +| `DisableUnconditionalProjectDeclarationCheck` | `false` | Set to `true` to suppress `PurviewConditionedProjectDeclaration`. The SDK reads `IsRoslynComponent`, `IsRoslynComponentOnly`, `IsPackable`, `PurviewTargetFrameworkSet` and `RootNamespace` from the project XML before the project body is evaluated, so it cannot evaluate a `Condition` on them and will not see the declaration — the SDK default applies instead. Declare those properties unconditionally, or set them in a file imported before this SDK. | | `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, compiler-generated output under the intermediate directory, no dependency file, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly and its PDB into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols). Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | | `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider package, added (with `PrivateAssets="all"`) to every non-test project. Match the repository host: `Microsoft.SourceLink.AzureRepos.Git` for Azure DevOps Services, `Microsoft.SourceLink.AzureDevOpsServer.Git` for on-premises Azure DevOps Server / TFS, `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git`. Set it **before the SDK import** — it is read during `Sdk.props`. See [SourceLink](SourceLink.md). | -| `DisableSourceLink` | `false` | Set to `true` **before the SDK import** to stop the SDK from adding the SourceLink package at all. An empty `SourceLinkPackageName` does not opt out; the SDK fills it with the GitHub default. | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider package, added (with `PrivateAssets="all"`) to every non-test project. Match the repository host: `Microsoft.SourceLink.AzureRepos.Git` for Azure DevOps Services, `Microsoft.SourceLink.AzureDevOpsServer.Git` for on-premises Azure DevOps Server / TFS, `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git`. Set it in `Directory.Build.props` so every project agrees. See [SourceLink](packaging/SourceLink.md). | +| `DisableSourceLink` | `false` | Set to `true` to stop the SDK from adding the SourceLink package at all. An empty `SourceLinkPackageName` does not opt out; the SDK fills it with the GitHub default. | | `EnableAssemblyNameGeneration` | `true` | When `true` (default), `AssemblyName` and `PackageId` derive from the fully evaluated `RootNamespace`. When explicitly `false`, standard .NET behaviour applies (`$(MSBuildProjectName)`). Explicit ``/`` in a `.csproj` always take precedence. | | `DisableProjectFileNamingConventionCheck` | `false` | Set to `true` to disable the validation that requires `MyProject\MyProject.csproj` naming alignment. | -| `DisableGenerateAssemblyInfoClass` | `false` | Set to `true` to disable the generated `AssemblyInfo` helper source. | -| `DisableGenerateAssemblyIdentifierClass` | `false` (`true` for test and shared-testing projects) | Set to `true` to disable the generated assembly identifier marker class. See [Assembly identifier class](Assembly-Name-Generation.md#assembly-identifier-class). | +| `DisableGenerateAssemblyInfoClass` | `false` (`true` for test and shared-testing projects) | Set to `true` to disable the generated `AssemblyInfo` helper source. | +| `DisableGenerateAssemblyIdentifierClass` | `false` (`true` for test and shared-testing projects) | Set to `true` to disable the generated assembly identifier marker class. See [Assembly identifier class](concepts/Assembly-Name-Generation.md#assembly-identifier-class). | | `AssemblyIdentifierClassName` | `Assembly` | Overrides the name of the generated assembly identifier marker class. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | | `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off (default), `1` preview pointer syntax (sets `LangVersion=preview` when the project has not chosen one), `2` updated rules (`updated-memory-safety-rules`). The updated rules are an experimental compiler feature, so tier `2` is opt-in. An unrecognised value fails with `PRCFG0001`. `AllowUnsafeBlocks` is never changed. | -| `PurviewRuntimeAsync` | `false` | Set to `true` to add `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | -| `IsClientSideProject` | *(detected)* | `true` when the project is downloaded to a browser: a WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, or a `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Set explicitly to override the detection either way. | +| `PurviewRuntimeAsync` | *(unset, which is treated as false)* | Set to `true` to add `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | +| `IsClientSideProject` | *(detected)* | `true` when the project is downloaded to a browser: a WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, or a `*.Client`/`*.ClientShared`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` (or `ClientShared`/`Components`) name. Set explicitly to override the detection either way. | | `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to every non-client-side project. `false` disables it. | -| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns off `PRSHR0001` and `PRSHR0002`. | +| `DisablePurviewServerSharedReferenceCheck` | *(unset, which is treated as false)* | Turns off `PRSHR0001` and `PRSHR0002`. | | `PurviewServerSharedReferenceCheckAsError` | `false` | Reports `PRSHR0001`/`PRSHR0002` as errors. | -| `NamespaceRemoveSuffix` | *(built-in list)* | Item type listing the suffixes stripped from `RootNamespace`. Remove an entry **after** the `Sdk.props` import to keep that suffix in the namespace, e.g. ``. **Roslyn component names are not in the list** — `SourceGenerator`, `SourceGenerators`, `SourceGeneration`, `Generators`, `Analyzers`, `CodeFixers` and `CodeFixes` are part of a component's identity, and a generator, its analyzers and its code fixes are separate assemblies that each need their own namespace. Add one with `` if a repository really wants it collapsed. See [Namespace stripping](Assembly-Name-Generation.md#namespace-stripping). | +| `AutoGeneratedAssemblyInfoFile` | `GeneratedAssemblyInfo.cs` | File name of the generated AssemblyInfo helper source, written under the intermediate `Properties/` folder. | +| `AutoGeneratedAssemblyIdentifierFile` | `GeneratedAssemblyIdentifier.cs` | File name of the generated assembly identifier source, written under the intermediate `Properties/` folder. | +| `CurrentYear` | *(the current year at build time)* | Year used in generated assembly metadata. | +| `NamespaceRemoveSuffix` | *(built-in list)* | Item type listing the suffixes stripped from `RootNamespace`. Remove an entry **after** the `Sdk.props` import to keep that suffix in the namespace, e.g. ``. **Roslyn component names are not in the list** — `SourceGenerator`, `SourceGenerators`, `SourceGeneration`, `Generators`, `Analyzers`, `CodeFixers` and `CodeFixes` are part of a component's identity, and a generator, its analyzers and its code fixes are separate assemblies that each need their own namespace. Add one with `` if a repository really wants it collapsed. See [Namespace stripping](concepts/Assembly-Name-Generation.md#namespace-stripping). | ## Target framework sets @@ -74,7 +81,7 @@ moves every consumer at once: | `Current` | `net10.0;net11.0` | The default. The current LTS plus the latest release. | | `Latest` | `net11.0` | Newest release only. | | `Supported` | `net10.0;net11.0` | A package that should work on anything still in support. | -| `Broad` | `net8.0;net9.0;net10.0` | Every shipped release — widest reach without committing to the newest major while it is pre-GA. | +| `Broad` | `net8.0;net9.0;net10.0` | Every shipped release except the newest major — widest reach without committing to it. | | `All` | `net8.0;net9.0;net10.0;net11.0` | `Broad` plus the newest major. | `net8.0` and `net9.0` are deliberately outside `Supported`: both are at or near end of support, so a new @@ -83,14 +90,16 @@ package should not imply a commitment to them. Choose `Broad` to keep them. `Broad` and `All` differ by one real product decision — whether the package commits to the newest release — so they are separate names rather than one set that silently widens every consumer of it. -A set is **always** in play: `Current` applies when nothing is selected, and resolves to every release -still in support. The value is named and overridable in one place, so moving a repository (or every +A set is **always** in play: `Current` applies when nothing is selected, and resolves to the current LTS +plus the latest release. The value is named and overridable in one place, so moving a repository (or every repository) between runtimes is a property change rather than an edit in each project. Overrides are layered, narrowest first: -1. an explicit `TargetFramework`/`TargetFrameworks` in the project always wins. Like the set selection - itself, this is read from the project XML, so it must be declared **unconditionally** to be seen; +1. an explicit `TargetFramework`/`TargetFrameworks` always wins, wherever it is set: the project (with + or without a `Condition`), a file it imports, or `Directory.Build.props` before or after the import. + Only `Directory.Build.targets` is too late, because the .NET SDK has already decided whether the + project multi-targets; 2. a `PurviewTargetFrameworkSet` in the project wins over one the repository selected; 3. a repository can redefine any set with `PurviewTargetFrameworks` — `PurviewTargetFrameworksSupported`, `PurviewTargetFrameworksBroad`, and so on — set before the SDK import. This is also how a set is pinned @@ -120,18 +129,18 @@ that early and is reported as `PurviewConditionedProjectDeclaration`. ## Repository metadata -The SDK also supports repo/site metadata properties for generated package metadata and docs links. These -values are consumed before the import and are useful when a repository wants a single shared project URL -or docs base URL instead of repeating it per project: +The SDK never sets package or site metadata such as `PackageProjectUrl`. A repository that wants one +shared value can define its own properties before the import and derive the metadata from them. This +repository does that in `src/Directory.Build.props` — a convention, not SDK properties; the SDK neither +defines nor reads them: -| Property | Default | Description | -| -- | -- | -- | -| `PurviewHomepage` | *(repo-specific)* | Base site URL for the repository or product docs. In this repo it is set to `https://purview.dev/`. | -| `PurviewProjectUrl` | *(repo-specific)* | Public project page URL; usually derived from `PurviewHomepage` plus a project path. | -| `PurviewDocsUrl` | *(repo-specific)* | Public docs URL; often derived from `PurviewHomepage` plus a docs path. | - -These values are not mandatory, but they provide a consistent place to keep package metadata and docs -links aligned with the repo's site structure. +```xml + + https://purview.dev/ + $(PurviewHomepage)projects/build-sdk/ + $(PurviewProjectUrl) + +``` ## Packable project defaults @@ -141,12 +150,12 @@ project has not supplied a value**: | Property | Default | Description | | -- | -- | -- | | `GenerateDocumentationFile` | `true` | Emits XML documentation. | -| `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/` via `PurviewPackAnalyzerPdb=true`). | +| `IncludeSymbols` | `true` | Produces a symbol package (`false` for an analyzer-only Roslyn component, `IsRoslynComponentOnly=true` — its PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/` via `PurviewPackAnalyzerPdb=true`). | | `SymbolPackageFormat` | `snupkg` | Symbol package format. Always the modern `.snupkg`; the legacy `.symbols.nupkg` is never produced by default. | | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | | `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | -| `IncludeSource` | `true` | Includes source files in the package. | +| `IncludeSource` | `false` | Packs source files into the symbol package. Off for every project, packable or not; SourceLink is how sources reach the debugger. | Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does **not** receive PDB files unless the project explicitly opts in (for example by adding `.pdb` to @@ -171,13 +180,21 @@ operations skip them silently. | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` at the repository root that enables MSBuild's multithreaded build (`-mt` plus `-nr:false`) on .NET 11. Set to `false` to stop bootstrapping it. | | `RepositoryDirectoryBuildRspFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `Directory.Build.rsp`. | -| `PurviewMultiThreadedBuild` | `true` | When `false`, a managed `Directory.Build.rsp` is rewritten to `-mt:false`, so opting out cannot leave the mode enabled. | +| `PurviewMultiThreadedBuild` | `true` | When `false`, a managed `Directory.Build.rsp` is rewritten to `-mt:false`, so opting out cannot leave the mode enabled. Left unset, a Blazor WebAssembly client writes `-mt:false` for the whole repository, because it cannot build under `-mt` (`PRBLZ0002`); a value you set always wins. | | `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports that an existing file differs from the SDK-provided one, and `Never` skips bootstrapping entirely. | | `PurviewRepoBootstrapCopyRetries` | `3` | Copy attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | | `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a bootstrap write that still fails after every retry is reported as a warning and the build continues. | | `PurviewSuppressCopyRetryWarnings` | `true` | Demotes MSB3026 copy retry notices to messages. Set to `false` to see every retry attempt. | +| `PurviewEditorConfigAutoUpdate` | `true` | Refreshes a managed `.editorconfig` (one opening with the SDK's version marker) when the SDK version changes. `false` keeps it exactly as it is. | +| `PurviewEditorConfigAdoptionWarning` | `true` | Warns, once per repository per SDK version, when the repository `.editorconfig` is not one the SDK manages and so will never be updated. | +| `PurviewEditorConfigAdoptionWarningInCI` | `false` | Reports the adoption warning on CI builds too. Off so that advice cannot fail a `TreatWarningsAsErrors` build. | +| `PurviewEditorConfigAdoptionStampPath` | *(.purview/editorconfig-adoption.cache beside the repository .editorconfig)* | Where the once-per-SDK-version record of the adoption warning is kept. | +| `PurviewRepositoryRootStrategy` | `Markers` | How the bootstrap finds the repository root: `Markers` walks up to the nearest root marker without starting a process; `Git` asks git for the worktree root. | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root. The first is definitive and searched to the top; the rest resolve nearest-first. | +| `EditorConfigFilePath` | *(the .editorconfig inside the installed SDK package)* | The SDK-provided `.editorconfig` injected into `@(EditorConfigFiles)` for build-time style enforcement. Set it before the `Sdk.props` import. | +| `RepoRoot` | *(discovered from CI workspace variables, then a .git marker or package.json above the project)* | Repository root used for version detection and bootstrapping. Set it before the `Sdk.props` import. | Bootstrap writes are staged into a temporary file and renamed into place, so editors and tools never observe a partially written `.editorconfig`, `global.json` or `Directory.Build.rsp`. Because every @@ -191,6 +208,11 @@ node left by a non-`-mt` build, which can cache a `RoslynCodeTaskFactory` task w and fail the first `-mt` build (`dotnet/msbuild#15213`). `-mt` still uses the MSBuild server (short-lived) for Server GC. +A repository with a Blazor WebAssembly client gets `-mt:false` instead, naming the client, because a +`net11.0` client cannot build under `-mt`. A client built under `-mt` anyway stops with `PRBLZ0002` +before the WebAssembly SDK fails. See +[Repositories with a Blazor WebAssembly client](Repository-Bootstrap.md#repositories-with-a-blazor-webassembly-client). + ## .NET 11 features Both compiler opt-ins are off by default and apply only to a `net11.0`+ target; the `net10.0` leg of a @@ -198,7 +220,7 @@ multi-targeted project is unchanged. | Property | Default | Notes | | -- | -- | -- | -| `PurviewRuntimeAsync` | `false` | When set to `true`, adds `runtime-async=on`. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | +| `PurviewRuntimeAsync` | *(unset, which is treated as false)* | When set to `true`, adds `runtime-async=on`. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off. | | `MemorySafetyRules` | `0` | `0` off (default); `1` sets the preview pointer-syntax tier (`LangVersion=preview` unless the project named its own); `2` enables `updated-memory-safety-rules`, which is an experimental compiler feature and therefore opt-in. `PRCFG0001` on an unknown value. | | `AllowUnsafeBlocks` | *(compiler default: false)* | Never set by the SDK. | @@ -211,7 +233,7 @@ each changes behaviour or cost for every project rather than improving one workl A `ServerShared*` project is referenced automatically by every project that cannot run in a browser. It is skipped for a WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a -`*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` project, and for `Shared`/`SharedTesting` +`*.Client`/`*.ClientShared`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` (or `ClientShared`/`Components`) project, and for `Shared`/`SharedTesting` projects (which are themselves referenced widely). `IsClientSideProject` can override the classification either way. @@ -229,7 +251,7 @@ records that the reference was judged safe. The SDK writes nothing into an agent directory. It used to mirror a package's `.agents/**` into the consuming repository; that is gone, because the skills CLI covers it. See -[Agent Skills](Agent-Skills.md). +[Agent Skills](tooling/Agent-Skills.md). ## Telemetry @@ -242,16 +264,16 @@ consuming repository; that is gone, because the skills CLI covers it. See | Property | Default | Description | | -- | -- | -- | -| `IsBffProject` | `true` for a web SDK project named `*Bff` | Classifies a BFF host. `false` opts out, `true` opts in. | +| `IsBffProject` | *(detected: true for a web SDK project named `*Bff`)* | Classifies a BFF host. `false` opts out, `true` opts in. | | `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project referencing a WebAssembly client. `false` opts out. | | `PurviewAutoBlazorClientReference` | `true` | A host `` references its `.Client` automatically. | | `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client. Set before the SDK import. | -| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`. | -| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps trim/AOT diagnostics as warnings. | -| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids added to `WarningsAsErrors`. | -| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`. | -| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error. | -| `PurviewBffExposesApi` | `true` if the BFF references `Microsoft.AspNetCore.OpenApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | +| `PurviewBrowserSafe` | *(unset, which is treated as false)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`. | +| `PurviewBrowserSafeWarningsAsErrors` | *(true on a PurviewBrowserSafe library, otherwise unset)* | `false` keeps trim/AOT diagnostics as warnings. | +| `PurviewBrowserSafeWarningCodes` | *(on a PurviewBrowserSafe library, CA1416, IL2001-IL2128, IL3000-IL3005 and IL3050-IL3059)* | The ids added to `WarningsAsErrors`. | +| `DisablePurviewBrowserSafeReferenceCheck` | *(unset, which is treated as false)* | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | *(unset, which is treated as false)* | Reports `PRBLZ0001` as an error. | +| `PurviewBffExposesApi` | *(true when the BFF references Microsoft.AspNetCore.OpenApi, otherwise unset)* | Whether a BFF keeps the OpenAPI interceptor namespace. | ## Testing @@ -262,8 +284,76 @@ consuming repository; that is gone, because the skills CLI covers it. See | `TestDataFramework` | `Bogus` | Test data provider. Supported values: `Bogus`, `None`. | | `DisableAutoInternalsVisibleTo` | `false` | Set to `true` to disable automatic `InternalsVisibleTo` generation for test types and shared testing projects. | -See [Engineering Principles](Engineering-Principles.md) for the policy-level testing guidance and -[Testing Wiring](Testing-Wiring.md) for the implementation details. +See [Engineering Principles](concepts/Engineering-Principles.md) for the policy-level testing guidance and +[Testing Wiring](testing/Testing-Wiring.md) for the implementation details. + +## Project detection + +The SDK classifies every project before its body is evaluated, and every result is also compiler-visible. +Override one by setting it before the `Sdk.props` import. See [Project Type Detection](concepts/Project-Type-Detection.md). + +| Property | Default | Description | +| -- | -- | -- | +| `IsCSharpProject` | *(detected from the project file extension)* | `true` for a `.csproj`; every C# default in the SDK is gated on it. | +| `IsSdkProject` | *(detected from the project)* | `true` when an `Sdk` value is found on the project or its import declaration. | +| `SdkProjectName` | *(detected from the project)* | The SDK named by the project or its import, for example `Microsoft.NET.Sdk.Web`. | +| `IsWebProject` | *(detected from the project)* | Marks a project with web behaviour (a web SDK project or an API host). | +| `IsWebSdkProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Web`. | +| `IsWorkerSdkProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Worker`. | +| `IsAspireHostProject` | *(detected from the project)* | `true` when the project SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | +| `IsBlazorWebAssemblyProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | *(detected from the project)* | `true` when the project SDK is `Microsoft.NET.Sdk.Razor`. | +| `IsBlazorHostProject` | *(detected from the project)* | `true` for a web SDK project that references a Blazor WebAssembly client. Says nothing about whether the host is a BFF. | +| `IsBffProject` | *(detected from the project)* | `true` for a web SDK project whose name ends with `Bff`; set `true` or `false` (in the project too) to override. Gates `PDS0008`/`PDS0009`. | +| `IsCLIProject` | *(detected from the project)* | `true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart` or `QuickStarts`. | +| `IsContainerProject` | *(detected from the project)* | `true` when a `Dockerfile` beside the project selects the container defaults. | +| `IsSharedProject` | *(detected from the project name)* | `true` for a `Shared` helper project (`Shared`, `SharedFramework`, ...), which other projects reference automatically. | +| `IsServerSharedProject` | *(detected from the project name)* | `true` for a `ServerShared` helper project, which only projects that cannot run in a browser reference automatically. | +| `IsSharedTestingProject` | *(detected from the project name)* | `true` for a shared testing helper project (`SharedTestingFramework`, ...). | +| `TestingType` | *(detected from the project name)* | Test category taken from a test project's name suffix (`Unit`, `Integration`, `E2E`, ...). | +| `TargetProjectName` | *(detected from the project name)* | For a test project, the project under test, inferred from the test project's name. | + +## Roslyn components and analyzers + +| Property | Default | Description | +| -- | -- | -- | +| `IsRoslynComponentOnly` | `false` (`true` for Roslyn components) | Produces an analyzer-only package: no `lib/` output, no symbol package, and the analyzer and its PDB packed under `analyzers/dotnet/cs/`. Set `false` for a dual-role component. Set it before the `Sdk.props` import. | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Stops `ValidateRoslynComponentCompilerSettings` failing the pack of a Roslyn component whose compiler defaults were changed. | +| `PurviewPackAnalyzerPdb` | *(true for an analyzer-only Roslyn component, otherwise unset)* | Packs a Roslyn component's PDB beside its analyzer under `analyzers/dotnet/cs/`, because a `.snupkg` cannot carry analyzer symbols. | +| `PurviewSourceGeneratorFrameworkAnalyzerValidation` | *(unset, which is treated as true)* | Set to `false` to turn off `PRSGD0005`, which checks that every file in a Roslyn component's analyzer closure is self-contained. | +| `PurviewAnalyzerClosurePermittedReferences` | `Microsoft.CodeAnalysis;System.Composition;System.Collections.Immutable;System.Memory;System.Threading.Tasks.Extensions;netstandard;mscorlib;System.` | Simple-name prefixes of host-provided assemblies an analyzer-closure file may reference without `PRSGD0005`. | +| `PurviewIncludeComponentReferenceAnalyzerClosure` | `true` | Includes a referenced Roslyn component's analyzer artifact in the referencing component's analyzer closure. | +| `PurviewImportProjectReferenceBuildTransitive` | `true` | Registers the `CompilerVisibleProperty` items a referenced project ships in `Sdk/buildTransitive`, so a package's own properties reach analyzers while developing it through `ProjectReference`. | + +## Agent skills + +See [Agent Skills](tooling/Agent-Skills.md) for the targets these configure. + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the skills CLI's lockfile is read from. | +| `PurviewSkillsSourceFolder` | `/skills` | Repository folder holding authored skills. | +| `PurviewSkillsAgents` | *(empty, so the skills CLI auto-detects agents)* | Comma-separated agents the skill targets install to, and that recommended commands name. When set, `CheckPurviewSkills`/`FixPurviewSkills` allow only those agents' skill directories. | +| `PurviewSkillsDryRun` | *(unset, which is treated as false)* | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print the CLI commands without running them. | +| `PurviewSkillsRunner` | `bun` | Package runner used to launch the skills CLI (`bun`, `npx`, `bunx`, `pnpm`, `yarn`, `deno`, `skills`, or any other). | +| `PurviewSkillsRunnerCandidates` | `bun;npx;bunx;pnpm;yarn;deno` | Runners probed on `PATH` after `PurviewSkillsRunner`. | +| `PurviewSkillsPackage` | `skills@1` | Package spec the executed launcher pins to. | +| `PurviewSkillsInstallRoot` | *(resolved)* | Overrides the repository root every skill target runs from. | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root. The first is definitive and searched to the top; the rest resolve nearest-first. | +| `PurviewSkillsCommand` | *(detected)* | Overrides the launcher entirely and skips runner detection. | +| `PurviewSkillsSource` | *(none)* | The source `InstallPurviewSkills` adds (`owner/repo`, optionally `#ref`); without it, install restores `skills-lock.json`. | +| `PurviewSkillsRepoRoot` | *(RepoRoot, else the nearest folder with an AGENTS.md)* | Evaluation-time guess at the repository root for the skill targets; a value you set outranks the marker walk. Set it before the `Sdk.props` import. | + +## Property schema validation + +For a package that ships its own `.props`/`.targets` and an MSBuild Editor sidecar. See +[MSBuild property schema and IntelliSense](configuration/Property-Schema.md). + +| Property | Default | Description | +| -- | -- | -- | +| `EnablePurviewPropertySchemaValidation` | `false` | Runs ValidatePurviewPropertySchema before Pack, failing it when a .buildschema.json sidecar and the package's .props/.targets disagree (PRPSC0001-PRPSC0004). For a package that ships MSBuild properties. Off, so no ordinary build or pack changes. | +| `PurviewPropertySchemaDirectory` | *(the project's Sdk/ folder, which PurviewAutoSdkPack ships)* | Folder whose .props/.targets files and .buildschema.json sidecars ValidatePurviewPropertySchema checks. | +| `PurviewPropertySchemaIgnoredProperties` | *(empty)* | Compiler-visible properties ValidatePurviewPropertySchema does not require a sidecar entry for, because the package does not own them (Version, RootNamespace, ...). | ## Compiler-visible SDK properties @@ -287,25 +377,42 @@ property that is not in this list. | `RepoRoot` | Repo root directory found via `.git` auto-discovery. | | `Version` | Package/assembly version, sourced from `package.json` when detection is enabled. | | `PackageVersion` | NuGet package version, sourced from `package.json` when detection is enabled. | +| `ProjectDir` | Absolute project directory path (with trailing separator) used by analyzers for project-relative file calculations. | +| `RootNamespace` | Effective project root namespace used by analyzers that validate or customize namespace conventions. | | `NamespacePrefix` | Required namespace prefix used to derive `RootNamespace`. | | `DisableNamespacePrefixCheck` | Disables the build error for missing `NamespacePrefix`. | | `TestingFramework` | Selected testing framework (`TUnit`, `Xunit`, or `None`). | | `SubstituteFramework` | Selected mocking provider (`TUnitMocks`, `NSubstitute`, or `None`). | | `TestDataFramework` | Selected test data provider (`Bogus` or `None`). | -| `SourceLinkPackageName` | SourceLink provider package added by the SDK (see [SourceLink](SourceLink.md)). | -| `DisableSourceLink` | Disables the automatic SourceLink package reference. | +| `SourceLinkPackageName` | SourceLink package ID added by the SDK. | +| `DisableSourceLink` | Disables automatic SourceLink integration. | | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | -| `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package. | +| `PurviewSkillsSourceFolder` | Repository folder holding authored agent skills (default `/skills`). | +| `PurviewSkillsAgents` | Comma-separated agents the skill targets install to (default empty: the CLI auto-detects). | +| `PurviewSkillsDryRun` | When `true`, `InstallPurviewSkills` and `UpdatePurviewSkills` print commands without running them. | +| `PurviewSkillsCommand` | Overrides the launcher entirely and skips runner detection. | +| `PurviewSkillsSource` | The source `InstallPurviewSkills` adds (`owner/repo`, optionally `#ref`); without it, install restores `skills-lock.json`. | +| `PurviewSkillsLockFile` | Where the skills CLI's `skills-lock.json` is read from (default `/skills-lock.json`). | +| `PurviewSkillsInstallRoot` | Overrides the repository root every skill target runs from. | +| `PurviewRepositoryRootMarkers` | Ordered markers identifying a repository root (default `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json`). | +| `PurviewSkillsRunner` | Package runner used to launch the skills CLI (default `bun`; also `npx`, `bunx`, `pnpm`, `yarn`, `deno`, `skills`). | +| `PurviewSkillsRunnerCandidates` | Runners probed on `PATH` after `PurviewSkillsRunner` (default `bun;npx;bunx;pnpm;yarn;deno`). | +| `PurviewSkillsPackage` | Package spec the executed launcher pins to (default `skills@1`). | +| `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | +| `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | Base delay between bootstrap write attempts (default `500`). | +| `PurviewRepoBootstrapCopyFailureAsError` | When `true` (default), a bootstrap write that still fails after every retry is an error. | +| `PurviewSuppressCopyRetryWarnings` | When `true` (default), built-in copy retry notices (`MSB3026`) are demoted to messages. | +| `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. | | `DisableGenerateAssemblyInfoClass` | Disables generated `AssemblyInfo` helper source. | | `DisableGenerateAssemblyIdentifierClass` | Disables the generated assembly identifier marker class. | -| `AssemblyIdentifierClassName` | Name of the generated assembly identifier marker class. | | `EnableAssemblyNameGeneration` | When `true` (default), `AssemblyName` derives from `RootNamespace`. | | `DisableAutoInternalsVisibleTo` | Disables automatic `InternalsVisibleTo` generation. | | `AutoIncludeUsings` | Controls SDK-added global usings. | | `IsCSharpProject` | True when the project is a `.csproj`. | | `IsTestProject` | True when project name ends with a supported test suffix. | -| `IsSharedTestingProject` | True for known shared testing helper project names; the project is forced to `OutputType=Library` (see `PurviewSharedTestingOutputType`). | +| `IsSharedTestingProject` | True for known shared testing helper project names. | | `TestingType` | Detected test category suffix from project name. | | `TargetProjectName` | Inferred target project name for test projects. | | `IsContainerProject` | True when Dockerfile markers indicate container defaults. | @@ -314,34 +421,53 @@ property that is not in this list. | `IsWebProject` | Marker used in SDK web-project behaviour. | | `IsWebSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | -| `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host`. | -| `IsCLIProject` | True when the project is a CLI project. | -| `IsSharedProject` | True when the project is a shared project. | -| `IsServerSharedProject` | True for a `ServerShared*` project, which only non-client-side projects reference automatically. | -| `IsClientSideProject` | True when the project's assemblies are downloaded to a browser (WebAssembly client, browser-safe library, Razor class library, or a client-side name segment). | -| `PurviewAutoServerSharedReference` | When `true` (default), adds a sibling `ServerShared` reference to a non-client-side project. | -| `DisablePurviewServerSharedReferenceCheck` | When `true`, suppresses `PRSHR0001`/`PRSHR0002`. | -| `PurviewServerSharedReferenceCheckAsError` | When `true`, reports `PRSHR0001`/`PRSHR0002` as errors. | -| `MemorySafetyRules` | C# 15 memory-safety tier applied to `net11.0`+ targets. | -| `PurviewRuntimeAsync` | When `true`, `net11.0`+ targets compile with `runtime-async=on`. Defaults to `false`; `UseRuntimeAsync=true` also opts in. | -| `PurviewMultiThreadedBuild` | When `true` (default), a managed `Directory.Build.rsp` enables MSBuild `-mt`. | -| `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` injected into `@(EditorConfigFiles)`. | -| `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig`. | +| `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | +| `IsBlazorWebAssemblyProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | +| `IsBlazorHostProject` | True for a web SDK project that references a Blazor WebAssembly client. | +| `IsBffProject` | True for a web SDK project named `*Bff`, or that sets `IsBffProject=true`. | +| `PurviewBrowserSafe` | When `true` on a class library, applies the browser-safe defaults. | +| `PurviewBrowserSafeWarningsAsErrors` | When `false`, browser-safe trim and AOT diagnostics fall back to the project's own warning policy (`TreatWarningsAsErrors`, `true` by default). | +| `DisablePurviewBrowserSafeReferenceCheck` | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | Reports `PRBLZ0001` as an error. | +| `DisablePurviewBlazorWebAssemblyDefaults` | Keeps the generic defaults on a WebAssembly client. | +| `PurviewAutoBlazorClientReference` | When `false`, a host does not reference its `.Client` automatically. | +| `PurviewBffExposesApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | +| `PurviewAutoServerSharedReference` | When false, a server-side project does not automatically reference a sibling ServerShared project. Client-side projects never get one. Defaults to true. | +| `DisablePurviewServerSharedReferenceCheck` | When true, a client-side or Shared project does not report a ServerShared project reference (PRSHR0001/PRSHR0002). | +| `PurviewServerSharedReferenceCheckAsError` | When true, PRSHR0001/PRSHR0002 are errors instead of warnings. Defaults to false. | +| `MemorySafetyRules` | The C# 15 memory-safety tier: 0 off (default), 1 preview syntax, 2 updated rules. The updated rules are an experimental compiler feature, so they are opt-in. AllowUnsafeBlocks is left unset. | +| `PurviewRuntimeAsync` | When true, a net11.0+ project compiles with the runtime-async compiler feature. Defaults to false; UseRuntimeAsync=true also opts in. | +| `PurviewMultiThreadedBuild` | When true (default), boots a Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). Left unset, a Blazor WebAssembly client writes -mt:false instead. | +| `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` that is injected into `@(EditorConfigFiles)`. | +| `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapEditorConfigToRepoRoot` | When `true` (default), copies the SDK `.editorconfig` to `RepositoryEditorConfigFilePath` if missing. | -| `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json`. | +| `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | -| `RepositoryDirectoryBuildRspFilePath` | Destination path for bootstrapping a physical repo-level `Directory.Build.rsp`. | -| `BootstrapMultiThreadedBuildRspToRepoRoot` | When `true` (default), creates a managed `Directory.Build.rsp` enabling `-mt`. | -| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json`. | +| `RepositoryDirectoryBuildRspFilePath` | Destination path for bootstrapping a repository-level Directory.Build.rsp when missing. | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | When true, bootstraps a managed Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`, `Directory.Build.rsp`). | +| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, falling back to the version stamped in at pack time). | | `CurrentYear` | Current year used in generated assembly metadata. | | `AutoGeneratedAssemblyInfoFile` | File name of the generated AssemblyInfo source file (written under the intermediate `Properties/` folder). | | `AutoGeneratedAssemblyIdentifierFile` | File name of the generated assembly identifier source file (written under the intermediate `Properties/` folder). | - -Roslyn components additionally expose `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, -`EnforceExtendedAnalyzerRules`, `Deterministic`, `ContinuousIntegrationBuild`, and -`EmbedUntrackedSources` as compiler-visible properties so downstream tooling can confirm the shipped -analyzer packages were built with the standard settings. +| `AssemblyIdentifierClassName` | Name of the generated assembly identifier marker class. | +| `IsCLIProject` | True when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart` or `QuickStarts`. | +| `IsSharedProject` | True when the project is a shared project. | +| `IsServerSharedProject` | True for ServerShared helper projects (ServerSharedFramework, etc.), which are referenced automatically only by projects that cannot run in a browser. | +| `IsClientSideProject` | True when the project's assemblies are downloaded to a browser: a WebAssembly client, a PurviewBrowserSafe library, a Razor class library, or a project named with a client-side suffix (*.Client, *.ClientShared, *.Browser, *.Wasm, *.WebAssembly, *.Components, or exactly ClientShared or Components). Set explicitly to override. | +| `LangVersion` | C# language version used to build the Roslyn component. Defaults to latest. | +| `Nullable` | Nullable reference-types context used to build the Roslyn component. Defaults to enable. | +| `TreatWarningsAsErrors` | Treats all compiler, analyzer and NuGet warnings as errors. Defaults to true for every project; set false to opt out. | +| `EnforceExtendedAnalyzerRules` | Enforces Roslyn analyzer best-practice rules. Defaults to true for Roslyn components. | +| `Deterministic` | Produce deterministic builds. Defaults to true. | +| `ContinuousIntegrationBuild` | True when building in a recognized CI environment, producing deterministic SourceLink output. | +| `EmbedUntrackedSources` | Embeds untracked source files for SourceLink. Defaults to true for Roslyn components. | +| `PurviewSourceGeneratorFrameworkAnalyzerValidation` | False to opt out of PRSGD0005 self-contained analyzer-closure validation. | + +`LangVersion`, `Nullable`, `TreatWarningsAsErrors`, `EnforceExtendedAnalyzerRules`, `Deterministic`, +`ContinuousIntegrationBuild` and `EmbedUntrackedSources` are exported for Roslyn components only, so downstream +tooling can confirm the shipped analyzer packages were built with the standard settings. ## Example: switch a repo to Xunit + NSubstitute and disable Bogus diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index bfd0bb4..09f4dac 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -15,15 +15,15 @@ pinned SDK version — bump it deliberately, because a released version can chan "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.1" + "Purview.BuildSdk": "2.1.0" } } ``` > [!NOTE] > The SDK can also bootstrap a `global.json` for you at the repository root when one is missing; see -> [Repository Bootstrap](Repository-Bootstrap.md). The value it writes is the version the package was -> packed as (`PurviewBuildSdkVersionForGlobalJson`). +> [Repository Bootstrap](Repository-Bootstrap.md). The value it writes is the restored package's version, +> else the version it was packed as (`PurviewBuildSdkVersionForGlobalJson`). ## 2. Create `Directory.Build.props` at the repo root @@ -62,9 +62,23 @@ build depend on the day it ran. > `Directory.Packages.props` at your repo root for CPM to work, even if it only contains the packages > the SDK adds automatically. +## 5. Add `package.json` at the repo root + +The product version comes from the `version` field of a repository-root `package.json`: + +```json +{ + "version": "1.0.0" +} +``` + +In a git repository with no root `package.json` the build fails, because version detection found a +repository root but no file in it. Add one, point `RootPackageJson` at another file, or set +`UsePackageJsonVersion=false` before the import. See [Version Detection](configuration/Version-Detection.md). + ## Required settings and versions -The four values above are the ones that decide whether the SDK behaves as documented: +These four values decide whether the SDK behaves as documented: | Setting | Where it goes | Notes | | -- | -- | -- | @@ -78,7 +92,8 @@ The four values above are the ones that decide whether the SDK behaves as docume A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, `net10.0;net11.0`. To change it: -- declare `TargetFramework` (one) or `TargetFrameworks` (many) **unconditionally** in the project; or +- declare `TargetFramework` (one) or `TargetFrameworks` (many) — an explicit value always wins over + every set, in the project, a file it imports, or `Directory.Build.props`; or - select a named set with `Supported` in `Directory.Build.props` or the `.csproj` (`Current`, `Latest`, `Supported`, `Broad`, `All`). @@ -86,10 +101,10 @@ See [Target framework sets](Configuration-Reference.md#target-framework-sets). ## Next steps -- Read the policy-level guidance in [Engineering Principles](Engineering-Principles.md). -- Understand how projects are detected and named in [Project Type Detection](Project-Type-Detection.md) - and [Project Naming Conventions](Project-Naming-Conventions.md). +- Read the policy-level guidance in [Engineering Principles](concepts/Engineering-Principles.md). +- Understand how projects are detected and named in [Project Type Detection](concepts/Project-Type-Detection.md) + and [Project Naming Conventions](concepts/Project-Naming-Conventions.md). - Browse every configurable property in the [Configuration Reference](Configuration-Reference.md). -- Learn how the package version is sourced in [Version Detection](Version-Detection.md). -- See how test projects are wired up in [Testing Wiring](Testing-Wiring.md). -- Read how packages are produced in [Packaging](Packaging.md). +- Learn how the package version is sourced in [Version Detection](configuration/Version-Detection.md). +- See how test projects are wired up in [Testing Wiring](testing/Testing-Wiring.md). +- Read how packages are produced in [Packaging](packaging/Packaging.md). diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index e775f1a..4ea9372 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -9,24 +9,87 @@ repo root inherits everything automatically. > The SDK imposes convention over configuration, enforcing certain styles and automations based on > project file names and repository layout. -## Start here - -- [Getting Started](Getting-Started.md) -- [Engineering Principles](Engineering-Principles.md) -- [Project Naming Conventions](Project-Naming-Conventions.md) -- [Project Type Detection](Project-Type-Detection.md) -- [Configuration Reference](Configuration-Reference.md) -- [Version Detection](Version-Detection.md) -- [Assembly Name Generation](Assembly-Name-Generation.md) -- [InternalsVisibleTo](InternalsVisibleTo.md) -- [Testing Wiring](Testing-Wiring.md) -- [Packaging](Packaging.md) -- [SourceLink](SourceLink.md) -- [SDK-Shipped Analyzers](Analyzers.md) -- [Code Fixes](Code-Fixes.md) -- [Repository Bootstrap](Repository-Bootstrap.md) -- [Agent Skills](Agent-Skills.md) -- [Release Flow](Release-Flow.md) +## How this wiki is organised + +The wiki is one folder per topic area, in eight sections, in reading order. The sidebar carries the +same sections in the same order, and `_Sidebar.md` is what the published documentation site reads for +page order. A page's folder is part of its published URL (`packaging/SourceLink.md` is +`/docs/build-sdk/packaging/sourcelink/`), so a page moves folder only when it changes topic. + +| Section | Where it lives | +| -- | -- | +| Get started | the wiki root, and `concepts/Engineering-Principles.md` | +| Conventions and detection | `concepts/` | +| Configuration | `Configuration-Reference.md` and `configuration/` | +| Testing | `testing/` | +| Analyzers and code fixes | `analyzers/` | +| Packaging | `packaging/` | +| Agent skills and tooling | `tooling/` | +| Contributing and releases | `development/` | + +Three pages stay at the wiki root, because the site depends on their URLs: `Getting-Started.md` is +the landing page, and `Configuration-Reference.md` and `Repository-Bootstrap.md` are the pages the +site's project catalogue deep-links. `DocumentationWikiTests` holds the folders, this page and the +sidebar to all of that. + +## Get started + +| Page | What it answers | +| -- | -- | +| [Getting Started](Getting-Started.md) | The minimal setup to adopt the SDK: `global.json`, `Directory.Build.props`, `package.json`, and a first build. | +| [Repository Bootstrap](Repository-Bootstrap.md) | Which repository-root files the SDK writes (`.editorconfig`, `global.json`), when, and how to opt out. | +| [Engineering Principles](concepts/Engineering-Principles.md) | The conventions the SDK relies on: layout, naming, namespace identity and test structure, and why. | + +## Conventions and detection + +| Page | What it answers | +| -- | -- | +| [Project Naming Conventions](concepts/Project-Naming-Conventions.md) | The exact naming and layout rules the SDK implements. | +| [Project Type Detection](concepts/Project-Type-Detection.md) | How the SDK decides what a project is (`IsTestProject`, `IsWebSdkProject`, …) and what follows from it. | +| [Assembly Name Generation](concepts/Assembly-Name-Generation.md) | How `RootNamespace`, `AssemblyName` and `PackageId` are derived. | + +## Configuration + +| Page | What it answers | +| -- | -- | +| [Configuration Reference](Configuration-Reference.md) | Every SDK property: its default, and whether it must be set before the `Sdk.props` import. | +| [Property Schema and IntelliSense](configuration/Property-Schema.md) | The `Sdk.props.buildschema.json` sidecar, csproj IntelliSense, and the checks that hold it to the code. | +| [Version Detection](configuration/Version-Detection.md) | How `Version` and `PackageVersion` come from `package.json`, and the strict mode. | +| [InternalsVisibleTo](configuration/InternalsVisibleTo.md) | The generated `InternalsVisibleTo` attributes for test projects. | + +## Testing + +| Page | What it answers | +| -- | -- | +| [Testing Wiring](testing/Testing-Wiring.md) | How test projects get their framework, mocking and test-data packages, and how to change them. | + +## Analyzers and code fixes + +| Page | What it answers | +| -- | -- | +| [SDK-Shipped Analyzers](analyzers/Analyzers.md) | The bundled analyzers and suppressors (`PDS*`), and what each one reports. | +| [Code Fixes](analyzers/Code-Fixes.md) | The IDE code fixes, and why they load only inside Visual Studio. | + +## Packaging + +| Page | What it answers | +| -- | -- | +| [Packaging](packaging/Packaging.md) | What a packable project gets: symbols, analyzer assets, the `Sdk/` folder and the README. | +| [SourceLink](packaging/SourceLink.md) | Choosing the SourceLink provider for the repository host, and opting out. | + +## Agent skills and tooling + +| Page | What it answers | +| -- | -- | +| [Agent Skills](tooling/Agent-Skills.md) | Installing, restoring, updating and tidying agent skills through the standard skills CLI (`PRSKL*`). | +| [Justfile](tooling/Justfile.md) | The shared `just` modules: the barrel, the recipes, the exported environment and `just bump`. | + +## Contributing and releases + +| Page | What it answers | +| -- | -- | +| [Release Flow](development/Release-Flow.md) | How a version is cut and published, and the pipeline recipes. | +| [Performance](development/Performance.md) | What the SDK costs at evaluation time, measured, and what has been done about it. | ## Feature highlights @@ -37,7 +100,7 @@ repo root inherits everything automatically. and the default boundaries between source, test, and shared test-support projects. - **C# defaults** — `net10.0;net11.0` TFMs (overridable, `netstandard2.0` for Roslyn components), `LangVersion=latest` (C# 15, including unions), `Nullable=enable`, `ImplicitUsings=enable`, - deterministic builds, `ManagePackageVersionsCentrally=true`. + `TreatWarningsAsErrors=true`, deterministic builds, `ManagePackageVersionsCentrally=true`. - **Code style** — an `.editorconfig` baked into the package, applied via `EditorConfigFilePath`, and auto-bootstrapped to the repo root if missing; `EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`. @@ -56,11 +119,10 @@ repo root inherits everything automatically. - **Bundled analyzers and code fixes** — three analyzers (`PDS0002`–`PDS0004`) and four suppressors (`PDS0001`, `PDS0005`, `PDS0006`, `PDS0007`), plus IDE code fixes for naming and extensions-namespace conventions. -- **Agent skills** — the SDK resolves which skills a restored package ships and the exact commit it was - built from, and reports what is missing or has drifted (`PRSKL0010`–`PRSKL0014`) when asked - (`-t:RecommendPurviewSkills`, or `-t:VerifyPurviewSkills` in CI) — never during an ordinary build. - Installing or updating them is a separate, explicit step (`dotnet msbuild -t:InstallPurviewSkills`) - that delegates to the skills CLI. The SDK itself writes nothing into an agent directory: the build-time +- **Agent skills** — a thin wrapper over the standard skills CLI (`bun x skills`): install a repository's + root `skills/` (`-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo`), restore `skills-lock.json`, + update every recorded source, and keep the agent directories tidy. Each is an explicit step that runs + once per solution from the repository root — never during an ordinary build. The SDK itself writes nothing into an agent directory: the build-time `.agents/**` mirror was removed outright, because the CLI owns those paths and two writers on one set of paths meant the mirror silently overwriting a pinned install. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md deleted file mode 100644 index 2ff0233..0000000 --- a/docs/wiki/Release-Flow.md +++ /dev/null @@ -1,72 +0,0 @@ -# Release Flow - -This repository uses the shared [`Purview.Build`](https://github.com/purview-dev/build) pipeline for -the full PR/release cycle, and plain `dotnet`/`just` commands for focused local work. - -## Versioning - -- **`package.json` is the single source of truth for the version.** The `version` field is read by - the SDK's [Version Detection](Version-Detection.md) logic and applied to `Version` and - `PackageVersion` for every build and pack. -- Current package version: read from `package.json` (`just current_version`). - -## Local workflow - -```text -dotnet tool restore # install local tools (csharpier, etc.) -just build # dotnet build src/BuildSdk.slnx --configuration Debug -just test # dotnet test with a TUnit tree-node filter -just lint-check # csharpier check -just lint-fix # csharpier format . -just pack # dotnet pack to ./artifacts -just pipeline-pr # restore, build, lint, tests, pack, package validation -just pipeline-local-release # restore, build, lint, tests, pack, local NuGet publish -``` - -## Pipeline workflows - -| Recipe | Purpose | -| -- | -- | -| `just pipeline-pr` | PR pipeline — restore, build, lint, tests, pack, package validation. | -| `just pipeline-build` | Build pipeline — restore, build, lint, pack, package validation (no tests). | -| `just pipeline-tests` | Tests pipeline — restore, build, lint, tests (no pack). | -| `just pipeline-release` | Release pipeline — restore, build, lint, tests, pack, publish to NuGet, GitHub release. | -| `just pipeline-local-release` | Release pipeline with a local NuGet publish (`--Release:Mode=LocalNuGet`). | - -The pipeline configuration lives in `purview-build.json`: - -```json -{ - "Build": { - "Solution": "src/BuildSdk.slnx", - "TestRoot": "src/tests", - "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*/" - }, - "PackValidation": { - "RequireSymbolPackage": false - }, - "Release": { - "Mode": "None" - } -} -``` - -## CI workflows - -- **PRs** — `.github/workflows/pr.yml` runs the PR pipeline on pull requests. -- **Releases** — `.github/workflows/release.yml` triggers on pushes to `main` and runs the shared - `purview-release.yml` workflow with `release-mode: NuGet`, which builds, packs, validates, and - publishes the package to NuGet. -- `continuousIntegrationBuild` is detected automatically from `CI`, `GITHUB_ACTIONS`, or `TF_BUILD` - environment variables for deterministic SourceLink output. - -## Testing - -- Tests use **Microsoft.Testing.Platform** (`global.json` sets `"runner": "Microsoft.Testing.Platform"`) - and **TUnit** conventions. -- CI runs the full suite on `ubuntu-latest`, so all tests and features must work identically on - Windows, Linux, and macOS — never hardcode platform-specific paths in tests or fixtures. -- The integration harness (`ProjectHarness`) creates throwaway projects under `Path.GetTempPath()`; - see `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs`. -- The SDK integration suite can be run on Linux locally via `just test-linux` (Docker-based). \ No newline at end of file diff --git a/docs/wiki/Repository-Bootstrap.md b/docs/wiki/Repository-Bootstrap.md index 5cc07cd..9328458 100644 --- a/docs/wiki/Repository-Bootstrap.md +++ b/docs/wiki/Repository-Bootstrap.md @@ -21,7 +21,7 @@ configuration on every compile and fails the build (`PRSGD0006`-`PRSGD0009`) whe overridden, hidden, or silenced through `NoWarn`. A stale copy that predates the current policy (for example one that still hides `IDE1006`) is therefore reported rather than obeyed — refresh it with `PurviewRepoBootstrapMode=Always`. Opt out with `DisablePurviewStylePolicyValidation=true`; see -[Style policy](Analyzers.md#style-policy). +[Style policy](analyzers/Analyzers.md#style-policy). The write is atomic (a temporary file is renamed into place) and retried quietly, so parallel projects sharing the repository root cannot corrupt it or fail the build by racing. An existing file is never @@ -89,13 +89,52 @@ The file is SDK-managed: a destination carrying the marker line is refreshed, on to the developer and is never touched. `PurviewMultiThreadedBuild=false` rewrites it to `-mt:false` rather than deleting it, so opting out cannot leave a stale copy enabling the mode. +### Repositories with a Blazor WebAssembly client + +A `net11.0` WebAssembly client cannot build under `-mt`. The WebAssembly SDK's `ReadWasmProps` task +comes from a custom task factory that cannot run in the out-of-process task host `-mt` moves it to, and +it fails with an `MSB4060` error that names the WebAssembly SDK rather than this file. The file is per +repository, not per project, so the rule is: + +- **A WebAssembly client writes `-mt:false`**, with a line naming itself, when + `PurviewMultiThreadedBuild` is unset: + + ```text + # Purview.BuildSdk - managed file + # Managed by Purview.BuildSdk. Delete the marker line to take ownership of this file. + # -mt:false: the Blazor WebAssembly client 'src/Shop.Client/Shop.Client.csproj' cannot build multithreaded (PRBLZ0002). + -mt:false + ``` + +- **Every other project keeps that file** while the named client's project file exists, and writes + `-mt` again once it does not. The client overwrites even a file another project created on the same + first build, so the race ends on `-mt:false`. The other way round, a project that read the file before + the client rewrote it looks again just before its own write, under a lock every project takes, and + keeps the client's file. Reading the file never fails a build that another project is writing it in. +- **A `PurviewMultiThreadedBuild` you set always wins**, in `Directory.Build.props`, a project file or + on the command line. **A file without the marker line is never rewritten.** + +A client built under `-mt` anyway stops with **`PRBLZ0002`** before `PrepareForBuild`, long before the +WebAssembly SDK's targets run. The message says where `-mt` came from (the command line, or the +`Directory.Build.rsp` it names) and how to fix it: `false` +in `Directory.Build.props`, or `-mt:false`. A repository upgrading from 2.0.x has a managed `-mt` file +that the first build has already read, so that build rewrites the file and stops with `PRBLZ0002` +saying so. The next build then succeeds. + +MSBuild 18 has no property that says `-mt` is on. The check looks at where an inline task runs: under +`-mt`, a `RoslynCodeTaskFactory` task always runs in the out-of-process task host, and otherwise in the +node (verified on .NET SDK 11.0.100). If a later MSBuild runs inline tasks in the node under `-mt`, the +check goes quiet rather than reporting falsely, and the WebAssembly SDK's own error returns. A +`net10.0` client builds under `-mt`, so the check does not apply to it. The response-file rule does, +because it costs a repository only the speed-up. + Like the other bootstraps it writes atomically, retries quietly, and honours `PurviewRepoBootstrapMode`: | Property | Default | Description | | -- | -- | -- | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates the `Directory.Build.rsp` when missing. | | `RepositoryDirectoryBuildRspFilePath` | *(auto-detected)* | Override the destination path. | -| `PurviewMultiThreadedBuild` | `true` | `false` writes `-mt:false` instead of `-mt`. | +| `PurviewMultiThreadedBuild` | `true` | `false` writes `-mt:false` instead of `-mt`. Unset, a WebAssembly client writes `-mt:false`. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | ## Retry and failure behaviour @@ -114,11 +153,16 @@ error by default, including the destination path and the underlying OS error. ## Repository root discovery -All bootstrap targets locate the repository root by: +All bootstrap targets locate the repository root the same way, chosen by `PurviewRepositoryRootStrategy`: + +1. With `PurviewRepositoryRootStrategy=Git` only, `git rev-parse --show-toplevel` from the + `Directory.Build.props` directory. The default, `Markers`, starts no process. +2. The `Directory.Build.props` directory, then up to four of its parents, in that order: the first that + holds a `.git` folder or file is the root. +3. Otherwise, the `Directory.Build.props` directory itself. -1. Running `git rev-parse --show-toplevel` from the `Directory.Build.props` directory. -2. Falling back to probing upward for a `.git` marker. -3. Finally falling back to the `Directory.Build.props` directory itself. +`Git` reports the worktree root, so for a submodule or a linked worktree it can answer differently from +the nearest `.git`. Set the `RepositoryEditorConfigFilePath`, `RepositoryGlobalJsonFilePath` or `RepositoryDirectoryBuildRspFilePath` property explicitly to override discovery. \ No newline at end of file diff --git a/docs/wiki/Version-Detection.md b/docs/wiki/Version-Detection.md deleted file mode 100644 index 7d90259..0000000 --- a/docs/wiki/Version-Detection.md +++ /dev/null @@ -1,52 +0,0 @@ -# Version Detection - -The SDK reads the `version` field from a repository-root `package.json` and applies it to both -`Version` and `PackageVersion` automatically. When no version source can be resolved (non-strict mode), -it silently falls back to `0.0.1`. - -## Behaviour - -When `UsePackageJsonVersion=true` (the default) or `UsePackageJsonVersion=Strict`, the SDK: - -1. **Explicit path** — if `RootPackageJson` is set, reads that file directly. -2. **Auto-discovery** — otherwise, locates the repo root from CI workspace variables - (`GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`), - then by walking up from the project directory looking for a `.git` marker or a `package.json`, and - reads `package.json` from there. - -The extracted `version` field is applied to both `Version` and `PackageVersion`. A build error is -raised when a `package.json` was resolved but can't be read, or when it contains no `version` field. -With `UsePackageJsonVersion=Strict`, the build also fails when no package.json source can be -discovered at all (for example, no explicit `RootPackageJson` and no discoverable `.git` marker or CI -workspace variable); in non-strict mode that case silently falls back to the `0.0.1` default. - -## Caching - -Version detection results are cached locally under the user's temporary directory -(`%TEMP%\Purview.BuildSdk\VersionDetection`, platform equivalent elsewhere) so repeated -evaluations don't re-scan the filesystem. Enable or disable with `EnableVersionDetectionCache` -(default `true`). - -## Logging - -Version detection logging is disabled by default. Set `VersionDetectionLogEnabled=true` to emit a -high-importance message showing the detected package version. - -## Important — set before the import - -Both `UsePackageJsonVersion` and `RootPackageJson` must be set **before** the -`` line in your `Directory.Build.props`. -The version logic runs during that import and cannot see properties set afterwards (for example in -individual `.csproj` files). - -```xml - - - Acme - - $(MSBuildThisFileDirectory)package.json - - - - -``` \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index af568b5..945169b 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,17 +1,34 @@ + + - [Home](Home.md) -- [Getting Started](Getting-Started.md) -- [Engineering Principles](Engineering-Principles.md) -- [Project Naming Conventions](Project-Naming-Conventions.md) -- [Project Type Detection](Project-Type-Detection.md) -- [Configuration Reference](Configuration-Reference.md) -- [Version Detection](Version-Detection.md) -- [Assembly Name Generation](Assembly-Name-Generation.md) -- [InternalsVisibleTo](InternalsVisibleTo.md) -- [Testing Wiring](Testing-Wiring.md) -- [Packaging](Packaging.md) -- [SourceLink](SourceLink.md) -- [SDK-Shipped Analyzers](Analyzers.md) -- [Code Fixes](Code-Fixes.md) -- [Repository Bootstrap](Repository-Bootstrap.md) -- [Agent Skills](Agent-Skills.md) -- [Release Flow](Release-Flow.md) +- **Get started** + - [Getting Started](Getting-Started.md) + - [Repository Bootstrap](Repository-Bootstrap.md) + - [Engineering Principles](concepts/Engineering-Principles.md) +- **Conventions and detection** + - [Project Naming Conventions](concepts/Project-Naming-Conventions.md) + - [Project Type Detection](concepts/Project-Type-Detection.md) + - [Assembly Name Generation](concepts/Assembly-Name-Generation.md) +- **Configuration** + - [Configuration Reference](Configuration-Reference.md) + - [Property Schema and IntelliSense](configuration/Property-Schema.md) + - [Version Detection](configuration/Version-Detection.md) + - [InternalsVisibleTo](configuration/InternalsVisibleTo.md) +- **Testing** + - [Testing Wiring](testing/Testing-Wiring.md) +- **Analyzers and code fixes** + - [SDK-Shipped Analyzers](analyzers/Analyzers.md) + - [Code Fixes](analyzers/Code-Fixes.md) +- **Packaging** + - [Packaging](packaging/Packaging.md) + - [SourceLink](packaging/SourceLink.md) +- **Agent skills and tooling** + - [Agent Skills](tooling/Agent-Skills.md) + - [Justfile](tooling/Justfile.md) +- **Contributing and releases** + - [Release Flow](development/Release-Flow.md) + - [Performance](development/Performance.md) diff --git a/docs/wiki/Analyzers.md b/docs/wiki/analyzers/Analyzers.md similarity index 92% rename from docs/wiki/Analyzers.md rename to docs/wiki/analyzers/Analyzers.md index d98646b..7edb69e 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/analyzers/Analyzers.md @@ -96,13 +96,14 @@ Examples: | `Extensions/TopLevel.cs` | *(global namespace)* | To avoid conflicting guidance, `IDE0130` is suppressed (PDS0005) for files in this root `Extensions/` -scope. The shipped `.editorconfig` also suppresses the namespace-conflict diagnostics this convention -can trigger (`CA1724`, `CS0436`, `CS1591`, `IDE0005`), so extension files never need `#pragma` -suppressions. Outside this scope, normal `IDE0130` behaviour remains unchanged. +scope. The shipped `.editorconfig` also turns off the namespace-conflict diagnostics this convention +can trigger (`CA1724`, `CS0436`, `CS1591`, `IDE0005`) for files under any `Extensions/` folder +(`[**/Extensions/**]`), so extension files never need `#pragma` suppressions. Outside this scope, normal `IDE0130` behaviour remains unchanged. ## PDS0003 — Prefer explicit type with target-typed `new()` -Flags `var` declarations that use a target-typed object creation initializer, preferring: +Flags a `var` declaration initialised with an object creation, preferring an explicit type with a +target-typed `new()`: ```csharp // ❌ flagged @@ -113,10 +114,10 @@ Service service = new(); ## PDS0004 — Acronym capitalization -`PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, -`Url`, `Dns`, `Http`, `Xml`, `DbContext`, ...) while still enforcing uppercase for acronyms such as -`Api` → `API`, `Ai` → `AI`, `Cpu` → `CPU`, `Gpu` → `GPU`, `Cli` → `CLI`, `Gui` → `GUI`, `Ram` → `RAM`, -and `Ssh` → `SSH`. By default the acronym-like segments `Http`, `Xml`, `Json`, `Id`, `Sdk`, `Sql`, +`PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Uuid`, `Url`, +`Dns`, `Http`, `Xml`, `DbContext`, ...) while still enforcing uppercase for the acronyms `Api` → `API`, +`Ai` → `AI`, `Ui` → `UI`, `Io` → `IO`, `Os` → `OS`, `Cpu` → `CPU`, `Gpu` → `GPU`, `Cli` → `CLI`, +`Gui` → `GUI`, `Ram` → `RAM`, `Ssh` → `SSH` and `Dsl` → `DSL`. By default the acronym-like segments `Http`, `Xml`, `Json`, `Id`, `Sdk`, `Sql`, `Uuid`, `Url`, `Dns`, `Tcp`, `Udp`, `Csv`, `Pdf`, `Html`, `Css`, `Ftp`, `Smtp`, `Imap`, and `Db` are exempt — `Db` is deliberately exempt so the prevalent EF Core/ADO.NET spellings (`DbContext`, `DbConnection`, `DbSet`, `CreateDbContext`) are never flagged; a repo that prefers `DB` can re-enable diff --git a/docs/wiki/Code-Fixes.md b/docs/wiki/analyzers/Code-Fixes.md similarity index 74% rename from docs/wiki/Code-Fixes.md rename to docs/wiki/analyzers/Code-Fixes.md index 351a52e..b4e2321 100644 --- a/docs/wiki/Code-Fixes.md +++ b/docs/wiki/analyzers/Code-Fixes.md @@ -11,10 +11,16 @@ The code fix for `PDS0004` renames the identifier to its correct-English capital `ApiClient` → `APIClient`) and updates all of its references across the solution. See [Analyzers](Analyzers.md) for the full rules and configuration options. -## PDS0002 — Fix extensions namespace +## PDS0002 — Sync namespace to Extensions folder structure -When a static extensions class is not at its conventional location (or is at that location but not -conventionally named), the **Move extensions class to conventional location** refactoring: +The code fix for `PDS0002` rewrites the file's namespace to the one its folders under `Extensions/` +imply (see [Analyzers](Analyzers.md#pds0002--extensions-namespace-rule)). + +## Move extensions class + +A refactoring, not tied to a diagnostic. When a static extensions class targets a single receiver type +but is not at its conventional location, **Move extensions class to conventional location** — or, when it +is already there but not conventionally named, **Rename extensions class to '…'**: - Re-paths the file to `Extensions//Extensions.cs`. - Renames the class to the receiver-derived name (for example an `IServiceCollection` extension @@ -41,8 +47,8 @@ class per receiver type** refactoring: ## Target-typed `new()` -The code fix for `PDS0003` rewrites a `var` declaration with a target-typed object creation -initializer to use an explicit type with `new()`: +The code fix for `PDS0003` (**Use explicit type and target-typed new**) rewrites a `var` declaration +initialised with an object creation to an explicit type with a target-typed `new()`: ```csharp // before diff --git a/docs/wiki/Assembly-Name-Generation.md b/docs/wiki/concepts/Assembly-Name-Generation.md similarity index 78% rename from docs/wiki/Assembly-Name-Generation.md rename to docs/wiki/concepts/Assembly-Name-Generation.md index 2d02abe..9888059 100644 --- a/docs/wiki/Assembly-Name-Generation.md +++ b/docs/wiki/concepts/Assembly-Name-Generation.md @@ -14,17 +14,26 @@ project references, restore, and packing all agree on the same identities. Set ## Resolved identities -| Project name | `NamespacePrefix` | `RootNamespace` | Resolved `AssemblyName` / `PackageId` | -| -- | -- | -- | -- | -| `Api` | `Acme` | `Acme.Api` | `Acme.Api` | -| `Acme.Api` | `Acme` | `Acme.Api` | `Acme.Api` (no double-prefix) | -| `Core.Infrastructure` | `Acme` | `Acme.Infrastructure` | `Acme.Core.Infrastructure` (`.Core` stripped from the namespace only) | -| `Shared` | `Acme` | `Acme` | `Acme.Shared` (full logical name, so it stays distinct) | -| `ServiceDefaults` | `Acme` | `Acme` | `Acme.ServiceDefaults` (full logical name, so it stays distinct) | -| `Acme` | `Acme` | `Acme` | `Acme` | - -Explicitly setting `` before the SDK import always wins: `AssemblyName`/`PackageId` -follow that override instead of re-appending a stripped suffix. +`RootNamespace` is stripped in two passes (see [Namespace stripping](#namespace-stripping)), so the +table shows both the evaluated value — what `dotnet msbuild -getProperty:RootNamespace` reports, and +what `AssemblyName`/`PackageId` are derived from — and the value the build compiles with. + +| Project name | `NamespacePrefix` | Evaluated `RootNamespace` | Build-time `RootNamespace` | Resolved `AssemblyName` / `PackageId` | +| -- | -- | -- | -- | -- | +| `Api` | `Acme` | `Acme.Api` | `Acme.Api` | `Acme.Api` | +| `Acme.Api` | `Acme` | `Acme.Api` | `Acme.Api` | `Acme.Api` (no double-prefix) | +| `Core.Infrastructure` | `Acme` | `Acme.Infrastructure` | `Acme` | `Acme.Core.Infrastructure` (`.Core` was stripped, so the full logical name is kept) | +| `Billing.Abstractions` | `Acme` | `Acme.Billing.Abstractions` | `Acme.Billing` | `Acme.Billing.Abstractions` | +| `Shared` | `Acme` | `Acme` | `Acme` | `Acme.Shared` (full logical name, so it stays distinct) | +| `ServiceDefaults` | `Acme` | `Acme` | `Acme` | `Acme.ServiceDefaults` (full logical name, so it stays distinct) | +| `Acme` | `Acme` | `Acme` | `Acme` | `Acme` | + +A `` set before the SDK import (for example in `Directory.Build.props`) replaces the +logical project name, and `AssemblyName`/`PackageId` follow it instead of re-appending a stripped +suffix. It is **still stripped**, though, by both passes: `Acme.Payments.Core.Abstractions` set there +evaluates to `Acme.Payments.Abstractions` (which becomes the `AssemblyName`) and builds as +`Acme.Payments`. Only a `` declared in the project file itself is left exactly as +written. Test projects keep their detected suffix: `Api.UnitTests` → `AssemblyName`/`PackageId` = `Acme.Api.UnitTests`, while `RootNamespace` remains `Acme.Api`. @@ -123,4 +132,4 @@ it has nothing to identify. Set `DisableGenerateAssemblyIdentifierClass=true` to project out. See [Project Naming Conventions](Project-Naming-Conventions.md) and -[InternalsVisibleTo](InternalsVisibleTo.md) for related naming behaviour. +[InternalsVisibleTo](../configuration/InternalsVisibleTo.md) for related naming behaviour. diff --git a/docs/wiki/Engineering-Principles.md b/docs/wiki/concepts/Engineering-Principles.md similarity index 89% rename from docs/wiki/Engineering-Principles.md rename to docs/wiki/concepts/Engineering-Principles.md index 1ad3a5b..7814b06 100644 --- a/docs/wiki/Engineering-Principles.md +++ b/docs/wiki/concepts/Engineering-Principles.md @@ -11,7 +11,7 @@ mechanics in detail: - [Project Naming Conventions](Project-Naming-Conventions.md) - [Project Type Detection](Project-Type-Detection.md) - [Assembly Name Generation](Assembly-Name-Generation.md) -- [Testing Wiring](Testing-Wiring.md) +- [Testing Wiring](../testing/Testing-Wiring.md) ## Core principles @@ -126,11 +126,13 @@ Key rules: 1. `RootNamespace` defaults to the logical project identity. 2. Duplicate tail segments are collapsed, so an already-prefixed project does not get double-prefixed. 3. Test suffixes such as `.UnitTests` and `.IntegrationTests` are removed from `RootNamespace`. -4. Known non-identity suffixes such as `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults` may be - stripped from `RootNamespace`. +4. Known non-identity suffixes are stripped from `RootNamespace` in two passes: `Core`, `EF`, `Shared`, + `ClientShared`, `ServiceDefaults` and the shared, server-shared and shared-testing project names while `Sdk.props` evaluates, then the + wider `NamespaceRemoveSuffix` list (`Infrastructure`, `Abstractions`, `Contracts`, ...) during the + build. See [Namespace stripping](Assembly-Name-Generation.md#namespace-stripping). 5. `AssemblyName` and `PackageId` normally default to the resolved project identity. -6. When suffix stripping would otherwise collapse two distinct artifacts into the same identity, - `AssemblyName` and `PackageId` keep the fuller logical identity so the artifacts remain distinct. +6. When the evaluation pass strips a segment, `AssemblyName` and `PackageId` keep the full logical + identity, so two distinct artifacts (`Shared` and `ServiceDefaults`, say) never collapse onto one name. Examples: @@ -157,7 +159,11 @@ For test projects: For non-test projects: -- sibling shared projects are discovered automatically +- sibling shared projects (`../Shared*/Shared*.csproj`) are discovered automatically, except by a + shared, shared-testing or Blazor WebAssembly project +- sibling `ServerShared*` projects are discovered automatically by every project that is not + client-side, shared, shared-testing or a `ServerShared` project itself, unless + `PurviewAutoServerSharedReference=false` (see [Project Type Detection](Project-Type-Detection.md#servershared-projects-isserversharedprojecttrue)) This means naming discipline is not cosmetic. It directly affects whether the SDK can connect the right projects without manual `ProjectReference` maintenance. @@ -221,7 +227,7 @@ Specialised dependencies stay explicit and intentional: - `TUnit.Aspire` for Aspire lifecycle or AppHost-backed integration tests - `Purview.Containers` for container-backed integration tests — one API over Microsoft WSL Containers (no Docker installation on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, Redis, - MySQL, RabbitMQ, Azurite and NATS. It is experimental and prerelease, so pin the version you adopt. + MySQL, RabbitMQ, NATS, Azurite and the Azure Key Vault and Service Bus emulators. It is experimental and prerelease, so pin the version you adopt. Reference `Testcontainers` directly only where a module does not exist yet Do not manually duplicate the default stack in every test project unless the repository has explicitly opted diff --git a/docs/wiki/Project-Naming-Conventions.md b/docs/wiki/concepts/Project-Naming-Conventions.md similarity index 74% rename from docs/wiki/Project-Naming-Conventions.md rename to docs/wiki/concepts/Project-Naming-Conventions.md index 9580bb5..520f4f8 100644 --- a/docs/wiki/Project-Naming-Conventions.md +++ b/docs/wiki/concepts/Project-Naming-Conventions.md @@ -9,9 +9,11 @@ The SDK applies several conventions automatically based on the `.csproj` filenam ## Defaults (no extra configuration) -`RootNamespace` is always derived from `$(NamespacePrefix).$(ProjectName)` and is the canonical -default public name. By default (`EnableAssemblyNameGeneration=true`), `AssemblyName` and `PackageId` -both follow the fully evaluated `RootNamespace`. Test projects retain their detected suffix so test +Unless a project sets its own, `RootNamespace` is derived from `$(NamespacePrefix).$(ProjectName)` and +is the canonical default public name. By default (`EnableAssemblyNameGeneration=true`), `AssemblyName` +and `PackageId` both follow the fully evaluated `RootNamespace` — or, when suffix stripping removed a +segment of the logical name, keep the full logical name (see +[Assembly Name Generation](Assembly-Name-Generation.md)). Test projects retain their detected suffix so test assemblies stay distinct from the source assembly. Set `EnableAssemblyNameGeneration=false` (before the SDK import) to opt out and use standard .NET behaviour (the `.csproj` filename): @@ -36,9 +38,10 @@ A build-time check (`PurviewProjectFileNameMismatch`) enforces that the `.csproj parent directory name, preventing inconsistent naming. Set `DisableProjectFileNamingConventionCheck=true` to opt out. -## Recommended structure: `src/` + `tests/` +## Recommended structure: `src/src` + `src/tests` -For larger repos, separate source and test projects into `src/` and `tests/` folders: +Separate source and test projects under `src/`, with the solution beside them — the canonical layout in +[Engineering Principles](Engineering-Principles.md#canonical-project-layout): ```text MyRepo/ @@ -46,21 +49,21 @@ MyRepo/ ├── Directory.Build.targets ├── Directory.Packages.props ├── global.json -├── src/ -│ ├── Api/ -│ │ └── Api.csproj -│ ├── Core/ -│ │ └── Core.csproj -│ └── SourceGenerator/ -│ └── SourceGenerator.csproj -├── tests/ -│ ├── Api.UnitTests/ -│ │ └── Api.UnitTests.csproj → IsTestProject=true, TestingType=Unit -│ ├── Api.IntegrationTests/ -│ │ └── Api.IntegrationTests.csproj -│ └── SharedTestingFramework/ -│ └── SharedTestingFramework.csproj → IsSharedTestingProject=true -└── package.json +├── package.json +└── src/ + ├── MyRepo.slnx + ├── src/ + │ ├── Api/ + │ │ └── Api.csproj + │ └── Domain/ + │ └── Domain.csproj + └── tests/ + ├── Api.UnitTests/ + │ └── Api.UnitTests.csproj → IsTestProject=true, TestingType=Unit + ├── Api.IntegrationTests/ + │ └── Api.IntegrationTests.csproj + └── SharedTestingFramework/ + └── SharedTestingFramework.csproj → IsSharedTestingProject=true ``` ## Flat structure: everything together @@ -90,16 +93,13 @@ Both layouts work identically — the SDK detects test projects by name suffix, ```sh # Create a source project -mkdir src/Api && cd src/Api -dotnet new classlib -n Api +dotnet new classlib -n Api -o src/src/Api # Create its unit tests -mkdir ../../tests/Api.UnitTests && cd ../../tests/Api.UnitTests -dotnet new classlib -n Api.UnitTests # SDK wires TUnit automatically +dotnet new classlib -n Api.UnitTests -o src/tests/Api.UnitTests # SDK wires TUnit automatically # Or flat: -mkdir Api.UnitTests && cd Api.UnitTests -dotnet new classlib -n Api.UnitTests +dotnet new classlib -n Api.UnitTests -o Api.UnitTests ``` ## Test project naming conventions @@ -127,7 +127,7 @@ detected exactly as readily as `MyProject.UnitTests`, and gets `TestingType=Gold > coverage and no category. Name it `.UnitTests`. What the SDK *does* ship as an explicit list is the `TestType` item group, and it governs -[InternalsVisibleTo](InternalsVisibleTo.md) generation rather than detection — a non-test project grants +[InternalsVisibleTo](../configuration/InternalsVisibleTo.md) generation rather than detection — a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name on it. A suffix outside that list still builds, still runs and still gets its category; it just cannot reach the target project's `internal` members. The full set is listed in [Project Type Detection](Project-Type-Detection.md#detection-flags). diff --git a/docs/wiki/Project-Type-Detection.md b/docs/wiki/concepts/Project-Type-Detection.md similarity index 89% rename from docs/wiki/Project-Type-Detection.md rename to docs/wiki/concepts/Project-Type-Detection.md index 4c83e65..a1ba2fb 100644 --- a/docs/wiki/Project-Type-Detection.md +++ b/docs/wiki/concepts/Project-Type-Detection.md @@ -41,13 +41,12 @@ is detected, and gets `TestingType=Golden` and `Category=Golden`, even though `G the SDK. The pattern requires the dot, so a project named plainly `UnitTests` is **not** detected as a test project at all — it gets no runner, no coverage and no category. -The `TestType` item group below governs [InternalsVisibleTo](InternalsVisibleTo.md) generation instead: a +The `TestType` item group below governs [InternalsVisibleTo](../configuration/InternalsVisibleTo.md) generation instead: a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name on it. A suffix outside the set still builds, still runs and still gets its category — it just cannot reach the target project's `internal` members. -The SDK ships the following full `TestType` set, in addition to the standard `Unit` and -`Integration` values: +The SDK ships the following full `TestType` set: `Acceptance`, `Accessibility`, `Approval`, `Architecture`, `Benchmark`, `BlackBox`, `Capacity`, `Chaos`, `Compatibility`, `Compliance`, `Component`, `Concurrency`, `Configuration`, `Contract`, `Deployment`, `E2E`, `EndToEnd`, `Endurance`, `Environment`, `Failover`, `Functional`, `Fuzz`, @@ -81,7 +80,7 @@ The SDK ships the following full `TestType` set, in addition to the standard `Un - `OutputType=Library` with `IsTestProject`/`IsTestingPlatformApplication` cleared, reasserted after the package props (which otherwise flip the project into an executable test host). The fixtures are consumed by the test assemblies, so staying a library avoids `CA1515` (which only targets executables). Set - `PurviewSharedTestingOutputType=Exe` before the SDK import to keep the package-driven test-host shape. + `PurviewSharedTestingOutputType=Exe` to keep the package-driven test-host shape. ### Shared projects (`IsSharedProject=true`) @@ -125,6 +124,9 @@ The SDK ships the following full `TestType` set, in addition to the standard `Un automatic `../Shared*` reference. `DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK import) restores the generic defaults. - `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe`. +- The managed `Directory.Build.rsp` written as `-mt:false`, and `PRBLZ0002` when a `net11.0`+ client is + built under `-mt` anyway: the WebAssembly SDK cannot build multithreaded. See + [Repositories with a Blazor WebAssembly client](../Repository-Bootstrap.md#repositories-with-a-blazor-webassembly-client). - Globalisation, trimming and the Blazor runtime settings are left to the Blazor SDK. ### Blazor hosts (`IsBlazorHostProject=true`) @@ -136,16 +138,16 @@ The SDK ships the following full `TestType` set, in addition to the standard `Un - No packages added. No OpenAPI interceptor namespace unless the BFF references `Microsoft.AspNetCore.OpenApi` or sets `PurviewBffExposesApi=true`. -- `PDS0008`/`PDS0009` on the authentication cookie (see [Analyzers](Analyzers.md)). +- `PDS0008`/`PDS0009` on the authentication cookie (see [Analyzers](../analyzers/Analyzers.md)). ### Browser-safe libraries (`PurviewBrowserSafe=true`) - `IsAotCompatible=true`, ``, and the trim, single-file, AOT and `CA1416` ids in `WarningsAsErrors` - each only where the project set nothing. - `PurviewBrowserSafeWarningsAsErrors=false` keeps them as warnings. + `PurviewBrowserSafeWarningsAsErrors=false` hands them back to `TreatWarningsAsErrors` (`true` by default). - The same `PRBLZ0001` reference check a client runs. -The README's [Blazor WebAssembly and BFF](../../README.md#blazor-webassembly-and-bff) section has the +The README's [Blazor WebAssembly and BFF](../../../README.md#blazor-webassembly-and-bff) section has the failure each behaviour prevents. ### Roslyn components (`IsRoslynComponent=true`) @@ -155,19 +157,19 @@ failure each behaviour prevents. - Compiler-generated output under the intermediate directory, no dependency file. - No symbol package by default; the PDB ships beside the analyzer in the package. - SourceLink with `EmbedUntrackedSources=true`, telemetry excluded. -- See [Packaging](Packaging.md) for how the analyzer assets are packed. +- See [Packaging](../packaging/Packaging.md) for how the analyzer assets are packed. ### Packable projects (`IsPackable=true`) -- `GenerateDocumentationFile`, `IncludeSource`, `IncludeSymbols` (`.snupkg`), `PublishRepositoryUrl`, - `EmbedUntrackedSources`, `DebugType=portable` defaults. -- See [Packaging](Packaging.md). +- `GenerateDocumentationFile`, `IncludeSymbols` (`.snupkg`), `PublishRepositoryUrl`, + `EmbedUntrackedSources` and `DebugType=portable` defaults; `IncludeSource` stays `false`. +- See [Packaging](../packaging/Packaging.md). ## Properties exposed to the compiler The detection flags (plus many SDK properties) are exposed to Roslyn analyzers and source generators via `CompilerVisibleProperty`, readable as `build_property.`. The full list is in the -[Configuration Reference](Configuration-Reference.md#compiler-visible-sdk-properties). +[Configuration Reference](../Configuration-Reference.md#compiler-visible-sdk-properties). ```csharp context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.IsBffProject", out var isBff); diff --git a/docs/wiki/InternalsVisibleTo.md b/docs/wiki/configuration/InternalsVisibleTo.md similarity index 82% rename from docs/wiki/InternalsVisibleTo.md rename to docs/wiki/configuration/InternalsVisibleTo.md index 7d5be6b..8cf6617 100644 --- a/docs/wiki/InternalsVisibleTo.md +++ b/docs/wiki/configuration/InternalsVisibleTo.md @@ -17,8 +17,9 @@ Two categories of friend assemblies are generated: 1. **TestType variants** — for each defined `TestType` (`Unit`, `Integration`, `Architecture`, `Contract`, `Functional`, …) the SDK emits the `$(AssemblyName).{TestType}Tests` alias plus the - `$(TargetProjectName).{TestType}Tests` and `$(MSBuildProjectName).{TestType}Tests` equivalents, so - the attribute resolves regardless of whether naming comes from an explicit `AssemblyName`, the SDK + `$(TargetProjectName).{TestType}Tests` and `$(MSBuildProjectName).{TestType}Tests` equivalents — each + only when it is non-empty and differs from the ones before, so no grant is duplicated — so the + attribute resolves regardless of whether naming comes from an explicit `AssemblyName`, the SDK default, or the raw project name. 2. **SharedTesting projects** — one per known shared testing project name (`SharedTestingFramework`, `SharedTestingInfrastructure`, etc.). By default (`EnableAssemblyNameGeneration=true`) with a @@ -26,7 +27,8 @@ Two categories of friend assemblies are generated: `EnableAssemblyNameGeneration=false` the raw name is used. `InternalsVisibleTo` is also generated for `DynamicProxyGenAssembly2` so Moq/NSubstitute dynamic -proxies can access internals. +proxies can access internals. That one is added to every C# project, test projects included, and is +not affected by `DisableAutoInternalsVisibleTo`. ## Disabling automatic InternalsVisibleTo diff --git a/docs/wiki/configuration/Property-Schema.md b/docs/wiki/configuration/Property-Schema.md new file mode 100644 index 0000000..88c0911 --- /dev/null +++ b/docs/wiki/configuration/Property-Schema.md @@ -0,0 +1,89 @@ +# Property Schema and IntelliSense + +Purview.BuildSdk describes every property it defines in an +[MSBuild Editor](https://marketplace.visualstudio.com/items?itemName=mhutch.msbuildeditor) sidecar +schema, `Sdk.props.buildschema.json`, packed beside `Sdk.props`. With the extension installed, a +`.csproj` or `Directory.Build.props` that imports the SDK gets completion for the SDK's properties, +their descriptions, their defaults, and — for a property such as `TestingFramework` — the allowed +values. + +## Getting IntelliSense + +1. Install **MSBuild Editor** in Visual Studio from the Marketplace link above. +2. Restore the repository once, so the SDK package is in the NuGet cache. +3. Open a project file and start typing a property name — `Purview`, `Testing`, `Namespace`. + +Nothing else is needed. The editor loads `.buildschema.json` for every file a project +imports, SDKs resolved through `global.json` `msbuild-sdks` included. Without the extension the file +is inert: it changes nothing about a build. + +## What the schema says, and how it stays right + +The schema is the **canonical** description of a property. Each entry has: + +- a **description**, which says when a property must be set before the `Sdk.props` import and, when a + property has no default, why (`No default: detected from the project`); +- a **type** — `bool`, `int`, a path, a TFM list, or an enumeration of the values the SDK acts on; +- a **default**, when the property has one. + +Every one of those claims is checked by this repository's test suite (`PropertySchemaTests`), which +fails when: + +- the SDK defines a public property the schema does not describe, or the schema describes one that + nothing defines or reads; +- a `CompilerVisibleProperty` has no entry, or an SDK analyzer reads a `build_property.*` that is not + exported; +- a stated default differs from what a plain class library evaluates to; +- a description says "set it before the import" and `Sdk.props` does not read the property — or the + other way round; +- the [Configuration Reference](../Configuration-Reference.md) or the README omits a property, or states a + different default; +- the file fails validation against the MSBuild Editor meta-schema (draft-04); +- the packed `.nupkg` lacks `Sdk/Sdk.props.buildschema.json`. + +`just properties-check` runs exactly those checks. + +## For package authors: `ValidatePurviewPropertySchema` + +A package that ships its own `.props`/`.targets` — under `Sdk/build/` or `Sdk/buildTransitive/`, which +`PurviewAutoSdkPack` packs to `build/` and `buildTransitive/` — can ship its own sidecar and have the +SDK check it: + +```xml + + true + + RootNamespace;Version + +``` + +The check then runs before `Pack`, and on demand: + +```bash +dotnet msbuild src/Acme.Kit/Acme.Kit.csproj -t:ValidatePurviewPropertySchema +``` + +| Id | Meaning | +| -- | -- | +| `PRPSC0001` | A `CompilerVisibleProperty` that no sidecar describes. | +| `PRPSC0002` | A sidecar entry no `.props`/`.targets` file defines, reads or exports. | +| `PRPSC0003` | A sidecar that is not valid JSON, or has no `"properties"` object. | +| `PRPSC0004` | A sidecar with no `.props`/`.targets` beside it, so MSBuild Editor never loads it. | + +It is off by default and never part of an ordinary build or design-time build. + +### Where the sidecar goes + +The name is the **full** name of the file the consumer imports plus `.buildschema.json`, in the same +folder: `build/Acme.Kit.props` → `build/Acme.Kit.props.buildschema.json`. `Acme.Kit.buildschema.json` +is never loaded (`PRPSC0004`). One sidecar per package is enough — the editor applies every loaded +sidecar project-wide — but a package that ships the same file under both `build/` and +`buildTransitive/` needs it in both. + +A sidecar is a new file in the package. A repository built with Purview.Build validates every package's +contents against `PackValidation.RequiredContent` in `.config/purview-build.json`, and a packed file that +list does not name fails the pipeline as *undeclared content*. Add each sidecar to its package's entry, +beside the file it describes. + +The `purview-buildsdk-msbuild-properties` agent skill walks through adding, renaming, deprecating and +removing a property, and the schema format. diff --git a/docs/wiki/configuration/Version-Detection.md b/docs/wiki/configuration/Version-Detection.md new file mode 100644 index 0000000..be06641 --- /dev/null +++ b/docs/wiki/configuration/Version-Detection.md @@ -0,0 +1,78 @@ +# Version Detection + +The SDK reads the `version` field from a repository-root `package.json` and applies it to both +`Version` and `PackageVersion` automatically. When no repository root can be discovered at all +(non-strict mode), it silently falls back to `0.0.1`. + +## Behaviour + +When `UsePackageJsonVersion=true` (the default) or `UsePackageJsonVersion=Strict` (compared +case-insensitively), the SDK resolves the file during the `Sdk.props` import: + +1. **Explicit path** — if `RootPackageJson` is set, reads that file directly. A relative path resolves + from the project directory, whatever directory the build runs from — so a value in + `Directory.Build.props` resolves per project; use `$(MSBuildThisFileDirectory)package.json` there. +2. **Auto-discovery** — otherwise it finds the repository root (`RepoRoot`), and reads `package.json` + from there. The first of these that gives an answer wins: + 1. a `RepoRoot` you set before the import; + 2. the repository root recorded in the [cache](#caching) for this project; + 3. the CI workspace variables `GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, + `BUILD_REPOSITORY_LOCALPATH` and `CI_PROJECT_DIR`, in that order, when the folder exists; + 4. the nearest folder above the project holding a `.git` file or folder; + 5. the nearest folder above the project holding a `package.json`. + +The value of the first `"version": "..."` pair in the file is applied to both `Version` and +`PackageVersion`. It is a text match, not a JSON parse, so keep the top-level `version` field ahead of +any nested object that also has one. + +`ValidatePackageJsonVersion` runs before `BeforeBuild`, `GenerateNuspec` and `Pack`, and fails the build +when: + +- `RootPackageJson` was set explicitly and that file does not exist; +- a repository root was discovered but there is no `package.json` in it — so a git repository without a + root `package.json` fails the build until you add one, set `RootPackageJson`, or set + `UsePackageJsonVersion=false`; +- a `package.json` was found but no `version` could be read from it; +- `UsePackageJsonVersion=Strict` and no source could be discovered at all (no explicit + `RootPackageJson` and no repository root). In non-strict mode that case silently keeps the `0.0.1` + default. + +`UsePackageJsonVersion=false` turns all of this off; `Version` keeps whatever was set elsewhere, or +`0.0.1`. + +## Caching + +Auto-discovery results are cached under the user's temporary directory, in +`Purview.BuildSdk/VersionDetection/`, one file per project directory, so later evaluations can skip the +root search. `VersionDetectionCacheFile` overrides the file. The cache records the repository root, the +`package.json` path and the version, and is written by `WriteVersionDetectionCache` before +`CollectPackageReferences`, `PrepareForBuild`, `GenerateNuspec` and `Pack`. It is only used when +`RootPackageJson` is not set explicitly, and the version itself is always re-read from the cached +`package.json`, so editing the file is picked up straight away. Enable or disable it with +`EnableVersionDetectionCache` (default `true`). + +## Logging + +Version detection logging is disabled by default. Set `VersionDetectionLogEnabled=true` to emit a +high-importance message, from `ValidatePackageJsonVersion`, showing the detected package version and +the file it came from. It can be set anywhere, because only the target reads it. + +## Important — set before the import + +`UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `EnableVersionDetectionCache` and +`VersionDetectionCacheFile` must be set **before** the +`` line in your `Directory.Build.props`. +The version logic runs during that import and cannot see properties set afterwards (for example in +individual `.csproj` files). + +```xml + + + Acme + + $(MSBuildThisFileDirectory)package.json + + + + +``` diff --git a/docs/wiki/Performance.md b/docs/wiki/development/Performance.md similarity index 95% rename from docs/wiki/Performance.md rename to docs/wiki/development/Performance.md index 5092ad2..cdfa15d 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/development/Performance.md @@ -49,7 +49,7 @@ not because evaluation was slow. ### Why the scans were not shared before -`Sdk.props:184` carries a warning that the project text must never be passed as a **quoted string +A comment in `Sdk.props`, beside the shared project-text properties, carries a warning that the project text must never be passed as a **quoted string literal**, because project text contains commas, parentheses and quotes that break MSBuild's property-function argument parsing. That warning is correct, and it is a different thing from passing the text as `$(_PurviewProjectText)`. @@ -61,8 +61,8 @@ expanded into the argument without its content being re-parsed as an argument li and the property form and the old nested form agree on every probe. The regression net is `BuildSdk.IntegrationTests`, which asserts evaluated property values directly -through `ProjectHarness.GetPropertyAsync` against real `dotnet msbuild` invocations. All 406 tests pass -unchanged. +through `ProjectHarness.GetPropertyAsync` against real `dotnet msbuild` invocations. All 406 tests at the +time passed unchanged. ## Measured: a real 39-project solution @@ -185,10 +185,9 @@ The intermediate 6.4 s / 1-compile case is worth keeping: it was Two findings were measured, judged, and left alone. Both are recorded so they are not re-litigated: -- **`ResolvePurviewSkillSources` still regex-scans the whole assets file.** It is now off the build - path entirely, so the cost is paid only by an explicitly invoked target: 1.47 s across 39 projects. - A substring pre-check (`.agents/skills`) would remove almost all of it, but doing so means - re-indenting ~90 lines of the SDK's largest file for no benefit to an ordinary build. +- **`ResolvePurviewSkillSources` regex-scanned the whole assets file.** It was taken off the build path + then, and later removed altogether with the package-to-skill mapping it served: the skill targets now + only run the skills CLI, and read no restore graph at all. - **`ImportProjectReferencedBuildTransitiveAssets` is not worth changing either**, and this is worth recording because it looked like the obvious next target. Its target-level timing (≈2 s across 38 projects) is inflated by nesting, and on a transitional build it appeared to cost 19 s. Toggling it diff --git a/docs/wiki/development/Release-Flow.md b/docs/wiki/development/Release-Flow.md new file mode 100644 index 0000000..9676c3e --- /dev/null +++ b/docs/wiki/development/Release-Flow.md @@ -0,0 +1,111 @@ +# Release Flow + +This repository uses the shared [`Purview.Build`](https://github.com/purview-dev/build) pipeline for +the full PR/release cycle, and plain `dotnet`/`just` commands for focused local work. + +## Versioning + +- **`package.json` is the single source of truth for the version.** The `version` field is read by + the SDK's [Version Detection](../configuration/Version-Detection.md) logic and applied to `Version` and + `PackageVersion` for every build and pack. +- Current package version: read from `package.json` (`just version`; `just bump major|minor|patch` changes it). + +## Local workflow + +```text +dotnet tool restore # install local tools (csharpier, etc.) +just build # dotnet build src/BuildSdk.slnx --configuration Debug +just test # dotnet test with a TUnit tree-node filter +just lint-check # csharpier check +just lint-fix # csharpier format . +just pack # dotnet pack to ./artifacts +just pipeline-pr # restore, build, lint, tests, pack, package validation +just pipeline-local-release # restore, build, lint, tests, pack, local NuGet publish +``` + +## Pipeline workflows + +| Recipe | Purpose | +| -- | -- | +| `just pipeline-pr` | PR pipeline — restore, build, lint, tests, pack, package validation. | +| `just pipeline-build` | Build pipeline — restore, build, lint, pack, package validation (no tests). | +| `just pipeline-tests` | Tests pipeline — restore, build, lint, tests, pack, package validation, with release explicitly off. | +| `just pipeline-release` | Release pipeline — restore, build, lint, tests, pack, publish to NuGet, GitHub release. | +| `just pipeline-local-release` | Release pipeline with a local NuGet publish (`--Release:Mode=LocalNuGet`). | +| `just pipeline-pack-validate` | Through pack and package validation, publishing nothing. | + +The recipes come from the `purview-build.just` and `purview-build-nuget.just` modules in +`.build/just/` — see the [`purview-buildsdk-justfile` skill](../../../skills/purview-buildsdk-justfile/SKILL.md). +Package validation checks each package against `PackValidation.RequiredContent`: a file a package +ships that the list does not name fails the run as *undeclared content*, so a new file under +`Sdk/` needs an entry there too. + +The pipeline configuration lives in `.config/purview-build.json`. Every `Build` stage — tests, lint, pack +and pack validation — runs unless the file or a `--Build:=false` argument turns it off: + +```json +{ + "Build": { + "Solution": "src/BuildSdk.slnx", + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*/" + }, + "PackValidation": { + "RequiredCompilerFlags": [ + "optimization=release" + ], + "RequireSymbolPackage": false, + "RequiredContent": { + "purview.buildsdk": [ + "analyzers/dotnet/cs/Purview.BuildSdk.Analyzers.dll", + "analyzers/dotnet/cs/Purview.BuildSdk.CodeFixers.dll", + "purview-logo-light.png", + "README.md", + "Sdk/.editorconfig", + "Sdk/Props/Defaults.props", + "Sdk/Props/PackedVersion.props", + "Sdk/Props/VersionDetection.props", + "Sdk/Sdk.props", + "Sdk/Sdk.props.buildschema.json", + "Sdk/Sdk.targets", + "Sdk/Targets/Blazor.targets", + "Sdk/Targets/Modern.targets", + "Sdk/Targets/PropertySchema.targets", + "Sdk/Targets/ServerShared.targets", + "Sdk/Targets/TargetFrameworkOwnership.targets", + "Sdk/Targets/VersionDetection.targets" + ], + "purview.skills.testing": [ + "README.md", + "lib/$(TFM)/Purview.Skills.Testing.dll", + "lib/$(TFM)/Purview.Skills.Testing.xml" + ] + } + }, + "Release": { + "Mode": "None" + } +} +``` + +## CI workflows + +- **PRs** — `.github/workflows/pr.yml` calls the shared `purview-build.yml` workflow twice: one job + builds, lints and packs without tests, and a matrix job runs each test project separately (the + matrix lists them by name, so a new test project must be added to it). +- **Releases** — `.github/workflows/release.yml` triggers on pushes to `main` and runs the shared + `purview-release` composite action with `release-mode: NuGet` and nuget.org Trusted Publishing, + optionally mirroring to GitHub Packages. +- `ContinuousIntegrationBuild` is set when the `CI`, `GITHUB_ACTIONS` or `TF_BUILD` environment variable + is `true`, for deterministic SourceLink output. + +## Testing + +- Tests use **Microsoft.Testing.Platform** (`global.json` sets `"runner": "Microsoft.Testing.Platform"`) + and **TUnit** conventions. +- CI runs the full suite on `ubuntu-latest`, so all tests and features must work identically on + Windows, Linux, and macOS — never hardcode platform-specific paths in tests or fixtures. +- The integration harness (`ProjectHarness`) creates throwaway projects under `Path.GetTempPath()`; + see `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs`. +- The SDK integration suite can be run on Linux locally via `just test-linux` (Docker-based). \ No newline at end of file diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index 03b9055..0000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,7 +0,0 @@ -# Purview Build SDK - -Centralized MSBuild conventions, analyzers, code fixes, and repository wiring. - -[Documentation overview](Home.md){ .md-button .md-button--primary } -[Get started](Getting-Started.md){ .md-button } -[Engineering principles](Engineering-Principles.md){ .md-button } diff --git a/docs/wiki/Packaging.md b/docs/wiki/packaging/Packaging.md similarity index 51% rename from docs/wiki/Packaging.md rename to docs/wiki/packaging/Packaging.md index fc0a7d0..08c742d 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/packaging/Packaging.md @@ -6,18 +6,20 @@ packaging workflows automatically: symbol packages, Roslyn analyzer assets, sour ## Packable project defaults -For projects where `IsPackable=true`, the SDK provides these defaults **only when the consuming -project has not supplied a value** — explicit values are always preserved: +For projects where `IsPackable=true`, a packed project ends up with these values **unless the +consuming project supplies its own** — explicit values are always preserved. Most are defaulted for +every project in `Props/Defaults.props`; `DebugType=portable` is the only one set for packable +projects alone: | Property | Default | Description | | -- | -- | -- | | `GenerateDocumentationFile` | `true` | Emits XML documentation. | -| `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/`). | +| `IncludeSymbols` | `true` | Produces a symbol package (`false` for an analyzer-only Roslyn component, `IsRoslynComponentOnly=true` — its PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/`). | | `SymbolPackageFormat` | `snupkg` | Symbol package format. Always the modern `.snupkg`; the legacy `.symbols.nupkg` is never produced by default. | | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | | `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | -| `IncludeSource` | `true` | Includes source files in the package. | +| `IncludeSource` | `false` | Source files are not packed into the symbol package; SourceLink is how sources reach the debugger. | A SourceLink provider package (`SourceLinkPackageName`) is added to every non-test project, and `PublishRepositoryUrl=true` writes the repository URL and commit into the `.nuspec`. See @@ -34,14 +36,19 @@ it defaults to `false` and only becomes `true` when a project explicitly opts in ## Roslyn components (`IsRoslynComponent=true`) -Packable Roslyn components automatically pack the built analyzer/generator assembly **and its PDB** -into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`). Set `PurviewPackAnalyzerPdb=false` only -when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols. -`IncludeBuildOutput=false` keeps `lib/` empty. +Packable, single-targeted Roslyn components automatically pack the built analyzer/generator assembly +**and its PDB** into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb` defaults to `true` for an +analyzer-only component). The PDB is skipped when `DebugType` is `embedded` or `none`. Set +`PurviewPackAnalyzerPdb=false` only when symbols are delivered another way — NuGet's `.snupkg` cannot +host `analyzers/dotnet/cs` symbols. For an analyzer-only component (`IsRoslynComponentOnly`, `true` by +default for a Roslyn component), `IncludeBuildOutput=false` keeps `lib/` empty and `NU5128` is +suppressed. -Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack with `PRSGD0001`– -`PRSGD0004` if the standard compiler defaults (`LangVersion`, `Nullable`, `TreatWarningsAsErrors`, -`EnforceExtendedAnalyzerRules`) are missing. Opt out with `DisableRoslynCompilerDefaultsValidation=true`. +Pack-time validation (`ValidateRoslynComponentCompilerSettings`, `BeforeTargets="Pack"`) fails the +pack when a standard compiler default has been removed or turned off: `PRSGD0001` (no `LangVersion`), +`PRSGD0002` (no `Nullable`), `PRSGD0003` (`TreatWarningsAsErrors` not `true`) and `PRSGD0004` +(`EnforceExtendedAnalyzerRules` not `true`). Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.CSharp.Workspaces`, `Microsoft.CodeAnalysis.Analyzers`) default to @@ -69,33 +76,37 @@ package: | Source path | Package path | | -- | -- | -| `Sdk/.agents/**` | `.agents/**` | | `Sdk/.github/**` | `.github/**` | | `Sdk/build/**` | `build/**` | | `Sdk/buildTransitive/**` | `buildTransitive/**` | | `Sdk/buildMultiTargeting/**` | `buildMultiTargeting/**` | -| `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. | package root | +| `Sdk/*.md`, `*.png`, `*.jpg`, `*.jpeg`, `*.gif`, `*.svg` | package root | | everything else under `Sdk/` | `Sdk/` | -The SDK automatically adds a `.gitignore` file into each second-level folder under `Sdk/.agents` with -the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`. -This ensures the copied folder structure remains discoverable in consuming repositories while the -content itself is ignored by Git. +There is no special mapping for `Sdk/.agents/**`: like any other folder it lands under `Sdk/`, and the +SDK writes no `.gitignore` files. With `PurviewAutoSdkPack=true` the SDK also defaults +`NoDefaultExcludes=true`, so dotfiles such as `Sdk/.editorconfig` are packed. MSBuild SDK packages (like `Purview.BuildSdk` itself) set `PurviewAutoSdkPack=false` and -pack their `Sdk/` contents explicitly instead. External files linked beneath `Sdk/` (via ``) -are packed with the same paths as physical `Sdk/` files. +pack their `Sdk/` contents explicitly instead. External files linked beneath `Sdk/` (a `None` item whose `Link` starts with `Sdk/`) are packed by +`IncludeLinkedSdkFiles` by the same table as physical files, so a linked `Sdk/README.md` packs as +`README.md` and a linked `Sdk/Props/X.props` as `Sdk/Props/X.props`. `SdkFolderPackingTests` packs both +kinds side by side to keep them identical. ## Repository README auto-inclusion -When the repo root is discoverable (`.git` marker or CI workspace variable), the repository-root -`README.md` is packed automatically for packable projects and registered via `PackageReadmeFile` — -but only when the file exists and `PackageReadmeFile` has not been configured explicitly. The SDK -skips the auto-inclusion if a README-named file is already being packed, so no duplicate readme items -are produced. No README is required; if the file is absent the pack succeeds without readme metadata. +For a packable project with no explicit `PackageReadmeFile`, the SDK registers +`PackageReadmeFile=README.md` and packs a README to the package root (`IncludeRepoReadmeInPackage`). +The project's own `README.md`, beside its `.csproj`, wins; otherwise the `README.md` at `RepoRoot` is +used. `RepoRoot` comes from a CI workspace variable (`GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, +`BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`), else the nearest `.git` or `package.json` above the +project. The SDK skips the auto-inclusion if a README-named file is already being packed (including a +linked `Sdk/README.md`), so no duplicate readme items are produced. No README is required; if neither +file exists the pack succeeds without readme metadata. ## Pack validation -The shared `Purview.Build` pipeline runs package validation on pack (`purview-build.json` sets -`PackValidation.RequireSymbolPackage=false` for this SDK repo). See -[Release Flow](Release-Flow.md) for the full flow. +The shared `Purview.Build` pipeline validates each package after pack, configured by +`.config/purview-build.json` `PackValidation`: for this repository `RequireSymbolPackage=false`, +`RequiredCompilerFlags` (`optimization=release`) and `RequiredContent`, the files each package must +contain. See [Release Flow](../development/Release-Flow.md) for the full flow. diff --git a/docs/wiki/SourceLink.md b/docs/wiki/packaging/SourceLink.md similarity index 90% rename from docs/wiki/SourceLink.md rename to docs/wiki/packaging/SourceLink.md index ea8b805..2bf89a9 100644 --- a/docs/wiki/SourceLink.md +++ b/docs/wiki/packaging/SourceLink.md @@ -18,7 +18,7 @@ It also matters beyond debugging. The `` el - `PublishRepositoryUrl=true` — the repository URL and commit land in the package's `.nuspec`. - `EmbedUntrackedSources=true` and `DebugType=portable` on packable projects, so the PDB is portable and SourceLink has a source for files the compiler generated or that are not tracked. -- `ContinuousIntegrationBuild` is set to `true` only when a real CI environment variable is present — +- `ContinuousIntegrationBuild` is set to `true` only when a real CI environment variable is `true` — `CI`, `GITHUB_ACTIONS` or `TF_BUILD`. A local build is deliberately **not** treated as CI, because SourceLink's CI-mode dirty-repository checks would otherwise fail a local `TreatWarningsAsErrors` build. Azure Pipelines sets `TF_BUILD`; GitHub Actions sets `GITHUB_ACTIONS`. @@ -27,9 +27,9 @@ It also matters beyond debugging. The `` el ## Choosing a provider for your host -Set `SourceLinkPackageName` **before the SDK import** — in `Directory.Build.props`, above the -``. It is read while `Sdk.props` evaluates, so a value in the `.csproj` body is too late and has -no effect. +Set `SourceLinkPackageName` in `Directory.Build.props`, above the ``, so every project uses the +same provider. A value in a `.csproj` also works: the SDK reads it only where it adds the package +reference, and MSBuild evaluates that after the project body. | Repository host | `SourceLinkPackageName` | Extra configuration | | -- | -- | -- | @@ -94,8 +94,9 @@ because the SDK adds it as an ordinary `PackageReference`. ``` -`DisableSourceLink=true` stops the SDK from adding the package at all. Set it **before the import** — it -is read during `Sdk.props`, and a value in the `.csproj` body is too late. Setting +`DisableSourceLink=true` stops the SDK from adding the package at all. Set it in `Directory.Build.props` +for the whole repository, or in a single `.csproj` — the SDK reads it only in the condition on the +package reference, which MSBuild evaluates after the project body. Setting `SourceLinkPackageName` to an empty value does **not** opt out: the SDK fills an empty value with the GitHub default. @@ -113,5 +114,5 @@ GitHub default alongside them. ## See also - [Packaging](Packaging.md) — symbol packages and portable PDBs -- [Configuration Reference](Configuration-Reference.md) — the full property list +- [Configuration Reference](../Configuration-Reference.md) — the full property list - [dotnet/sourcelink](https://github.com/dotnet/sourcelink) — upstream documentation and the provider list diff --git a/docs/wiki/Testing-Wiring.md b/docs/wiki/testing/Testing-Wiring.md similarity index 84% rename from docs/wiki/Testing-Wiring.md rename to docs/wiki/testing/Testing-Wiring.md index ae3771a..f2f4135 100644 --- a/docs/wiki/Testing-Wiring.md +++ b/docs/wiki/testing/Testing-Wiring.md @@ -1,7 +1,7 @@ # Testing Wiring This page explains how the SDK implements the higher-level testing conventions in -[Engineering Principles](Engineering-Principles.md). +[Engineering Principles](../concepts/Engineering-Principles.md). The SDK wires the testing stack for you based on three properties that must be set **before** the SDK import: @@ -43,23 +43,28 @@ added. ## Shared test configuration -All test and shared-testing projects get: +Test **and** shared-testing projects get: - A **test-context rule set** (`PurviewTestContextNoWarn`, overridable before the SDK import): the production API-surface rules (`CA1002`, `CA1012`, `CA1034`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, `CA1515`, `CA1707`) are exempt, because test classes/fixtures are public, test names use `Method_Scenario_Expectation`, and helpers take unvalidated fixture parameters. Style rules (`IDE0040`, field naming, formatting, `IDE1006` naming) are still enforced. - `DisablePurviewTestContextRuleSet=true` enforces the production rules too. -- `CollectCoverage=true` with coverage exclusions for `[NSubstitute*]`, `[TUnit.*]`, `[xunit.*]`, - `[Microsoft.Testing.*]`, `[Microsoft.NET.Test*]`, and `[Bogus*]`. -- `ExcludeByAttribute` for `ExcludeFromCodeCoverageAttribute`. -- `IsPackable=false`, `IsPublishable=false`, `MaxCpuCount=0`, `DisableGenerateAssemblyInfoClass=true`, + `DisablePurviewTestContextRuleSet=true` enforces the production rules too. `CA1822` and `CS1591` are + also added to `NoWarn`. +- `IsPackable=false`, `IsPublishable=false`, `DisableGenerateAssemblyInfoClass=true` and `DisableGenerateAssemblyIdentifierClass=true` (the test assembly is the one asserting, so it has nothing to identify — see - [Assembly identifier class](Assembly-Name-Generation.md#assembly-identifier-class)). -- Disabled native instrumentation. + [Assembly identifier class](../concepts/Assembly-Name-Generation.md#assembly-identifier-class)). - An `[assembly: ExcludeFromCodeCoverage]` attribute. +- The mocking and test-data packages selected by `SubstituteFramework` and `TestDataFramework`. + +Test projects (`IsTestProject=true`) only also get: + +- `CollectCoverage=true` with coverage exclusions for `[NSubstitute*]`, `[TUnit.*]`, `[xunit.*]`, + `[Microsoft.Testing.*]`, `[Microsoft.NET.Test*]`, and `[Bogus*]`, and `ExcludeByAttribute` for + `ExcludeFromCodeCoverageAttribute`. +- `MaxCpuCount=0`, and native instrumentation disabled. ## Substitution frameworks @@ -85,7 +90,7 @@ Projects named `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedT - They are forced to `OutputType=Library` (with `IsTestProject`/`IsTestingPlatformApplication` cleared) after package props run, because test packages such as `TUnit.Engine` otherwise turn them into executable test hosts - which would make `CA1515` demand that their fixtures become internal while the - test assemblies still consume them. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to + test assemblies still consume them. Set `PurviewSharedTestingOutputType=Exe` to opt back into that shape. - Test projects automatically reference the sibling shared testing project via `../SharedTesting*/SharedTesting*.csproj`. @@ -106,5 +111,5 @@ and are filtered with TUnit tree-node filters: dotnet test --treenode-filter "/*/*/*/*[Category=Unit]/" ``` -See [Project Naming Conventions](Project-Naming-Conventions.md) for the recognised test suffixes and -[InternalsVisibleTo](InternalsVisibleTo.md) for how test assemblies access internals. +See [Project Naming Conventions](../concepts/Project-Naming-Conventions.md) for the recognised test suffixes and +[InternalsVisibleTo](../configuration/InternalsVisibleTo.md) for how test assemblies access internals. diff --git a/docs/wiki/tooling/Agent-Skills.md b/docs/wiki/tooling/Agent-Skills.md new file mode 100644 index 0000000..71dfebb --- /dev/null +++ b/docs/wiki/tooling/Agent-Skills.md @@ -0,0 +1,296 @@ +# Agent Skills + +[Agent Skills](https://skills.sh/) are folders of `SKILL.md` guidance that coding agents load on +demand. A repository publishes them in its root `skills/` folder, and the standard +[`skills` CLI](https://skills.sh/) installs them — `bun x skills add owner/repo` — and records each +install in `skills-lock.json`. `Purview.BuildSdk` adds nothing bespoke to that: its skill targets are +a thin wrapper that runs the CLI from the repository root, once per solution, and keeps the agent +directories tidy. + +> Nothing on this page runs as a side effect of a restore, a build, a design-time build or a test run. +> Each target runs only when it is named, and the ones that change the repository refuse to run inside +> a build (`PRSKL0009`). + +## Publication + +Skills are authored at the **repository root**, in `skills//SKILL.md`, and published through +the skills CLI, which clones the git repository and discovers that root `skills/` folder. That is the +only route: the SDK packs no skills into a NuGet package, and reads none from one. + +```bash +bun x skills add purview-dev/build-sdk # every skill +bun x skills add purview-dev/build-sdk --skill purview-buildsdk-testing +bun x skills add purview-dev/build-sdk --list # see what is there, install nothing +``` + +Skills live **alongside the library they describe**, in that library's repository, rather than in one +central skills repository, so they change in the same commit as the code. + +## Authoring and validation + +`bun x skills init ` scaffolds a skill; move it under `skills//` and adapt the frontmatter. +`ValidatePurviewSkills` runs `BeforeTargets="Pack"`, so a malformed skill fails the build of the +repository that owns it rather than reaching the CLI, which would silently skip it. It checks that: + +- every folder under the skills root has a `SKILL.md`, +- that file opens with YAML frontmatter carrying `name` and `description`, +- `name` matches the folder name, because the CLI installs by frontmatter name while a human reads + the folder name, and +- every `references/...` path the body links to exists. + +Failures are reported as `PRSKL0005`. The fuller rule set — names, descriptions, optional fields, +bundled links, body size — is `Purview.Skills.Testing`, run as tests (see the +`purview-buildsdk-skill-tests` skill). Prose quality is deliberately not checked. + +## The targets + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo[#ref] +dotnet msbuild -t:InstallPurviewSkills +dotnet msbuild -t:UpdatePurviewSkills +dotnet msbuild -t:CheckPurviewSkills +dotnet msbuild -t:FixPurviewSkills +``` + +| Target | Runs | Changes the repository | +| -- | -- | -- | +| `InstallPurviewSkills` with `PurviewSkillsSource` | `skills add "" --skill "*"` — every skill in that repository's root `skills/`; append `#` to pin | yes | +| `InstallPurviewSkills` | `skills experimental_install` — restore exactly what `skills-lock.json` records | yes | +| `UpdatePurviewSkills` | `skills add "" --skill "*"` once per unique lockfile source, at its latest | yes | +| `CheckPurviewSkills` | nothing; fails (`PRSKL0015`) for an agent directory `PurviewSkillsAgents` does not name | no | +| `FixPurviewSkills` | nothing; removes the CLI's links from those directories | yes | + +`-p:PurviewSkillsDryRun=true` makes install and update print every command and run none. With no +`PurviewSkillsSource` and no `skills-lock.json` there is nothing to install or update, which is +`PRSKL0007`. + +**Update is `skills add`, never `skills update`.** `skills add` reinstalls every skill it selects (the +CLI cleans each skill's folder and copies it again), so one re-add both refreshes the installed skills +and adds any the source has gained; `skills update` only re-fetches what the lockfile already lists. +Every source is attempted even when one fails, and each failure is named as `PRSKL0008`. + +**Name the solution.** Pointed at a solution, MSBuild runs a target in every project, so each project +hands the work to one elected leader — the first solution project, by path, that imports the SDK — and +MSBuild's result cache runs the leader's target once. A single project works too. For `just` recipes, +see the [`purview-buildsdk-justfile` skill](../../../skills/purview-buildsdk-justfile/SKILL.md), whose +`skills.just` module carries them. + +**Never during a build.** No skill target has `BeforeTargets`/`AfterTargets`, nothing in the SDK +depends on one, and install, update and fix fail with `PRSKL0009` when `BuildingProject`, +`DesignTimeBuild` or `MSBuildIsRestoring` is true — which catches a consumer's own +`AfterTargets="Build"` hook. A git hook or a `just` recipe dependency that runs a target on its own is +not a build, so it is not caught: do not wire one up. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**: no `--agent` is passed and the CLI auto-detects the +agents installed on the machine. Set it to choose. Each agent becomes its own `--agent` flag, because +the CLI does not split a comma-separated value. A restore (`experimental_install`) is passed no agent +flag. + +```xml + + universal + +``` + +On a command line, `dotnet msbuild -p:` itself splits on `,` and `;`, so escape the separator +(`-p:PurviewSkillsAgents=claude-code%2Ccodex`) or quote the whole value +(`-p:PurviewSkillsAgents=\"claude-code codex\"`); spaces separate agents too. + +Once agents are named, `CheckPurviewSkills` enforces them: any agent skill directory at the repository +root that belongs to an agent not named fails as `PRSKL0015`. Two directories are always allowed — +`.agents/skills`, the CLI's canonical store that every agent directory links into, and the authored +`skills/` folder, even though openclaw installs into a root `skills/`. With `PurviewSkillsAgents` +empty, every agent directory is a legitimate destination and both targets report that there is +nothing to enforce. + +`FixPurviewSkills` removes what is provably the CLI's: links (symlinks, and junctions on Windows) — +the link only, never its target — and then any folder left empty. A real file is kept and reported, +because it cannot be told from hand-written content; for some agent selections the CLI writes copies +rather than links, and those are kept too. + +The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the CLI's agent +table. The CLI adds agents independently of this SDK, so an agent the map does not know is `PRSKL0016` +(a warning), fixed by adding an item: + +```xml + + + +``` + +## The lockfile + +`skills-lock.json` is the CLI's own record of what is installed and where it came from. The SDK reads +it for `UpdatePurviewSkills` and introduces no competing lockfile and no competing metadata. Commit it: +a fresh clone then restores the same set with `-t:InstallPurviewSkills`. Everything the CLI writes into +the agent directories is reproducible from it, so ignore those: + +| Path | Written by | Tracked | +| -- | -- | -- | +| `skills//` | authored by hand | yes | +| `skills-lock.json` | the CLI | yes | +| `.agents/skills//` | the CLI's "universal" location | no | +| `.claude/skills/`, `agent/skills/`, … | per-agent links into it | no | + +Ignore `.agents/skills/*` specifically rather than the whole of `.agents/`, so authored +`.agents/agents/` and `.agents/prompts/` content stays tracked. A repository-local skill — guidance +about working in that repository, never published — can be authored in `.agents/skills//` and +un-ignored by name. + +### One installer + +Skills have exactly one installer: **the skills CLI**. The SDK writes nothing into an agent directory, +at all. Earlier versions mirrored a package's whole `.agents/**` folder into the consuming repository; +that is gone, because the CLI's universal install location is `.agents/skills/` — exactly where the +mirror wrote — and two writers on one set of paths meant the mirror silently overwriting a pinned +install on the next build. + +### Installing in the repository that authors the skills + +A repository that authors skills it also uses can install from its own working tree, so an edit to +`skills//SKILL.md` reaches its agents without a push: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=. +``` + +The CLI records that portably — `"source": "."`, `"sourceType": "local"`. `Purview.BuildSdk` itself does +not: every skill it authors exists for *other* repositories, so an agent working here reads them at +their authored path. + +## Package runners + +The CLI is published to npm, and every package runner spells "run this npm package" its own way. +`PurviewSkillsRunner` picks one; it defaults to **`bun`** (`bun x skills`). + +| `PurviewSkillsRunner` | Executed | +| -- | -- | +| `bun` *(default)* | `bun x skills@1` | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` | +| `deno` | `deno run -A npm:skills@1` | +| `skills` | `skills` | + +A runner this SDK has never heard of still works: it falls through to ` skills@1`. `yarn dlx` +needs Yarn 2+. The launcher pins the CLI to a major so a breaking release cannot change what a target +does; `PurviewSkillsPackage` (default `skills@1`) overrides the pin. + +### Detecting the CLI + +A target probes `PATH` rather than assuming the configured runner is present, honouring `PATHEXT` on +Windows where these are `.cmd` shims, in this order: + +1. `skills` — used as-is if it is already on `PATH`. No download, works offline. +2. `$(PurviewSkillsRunner)` — the configured preference. +3. `$(PurviewSkillsRunnerCandidates)` — `bun;npx;bunx;pnpm;yarn;deno` by default. + +The runner actually found wins over the configured preference, because it is the one that can really +run. `PATH` is read directly rather than by spawning a version check: deterministic, no process, and +it cannot hang on a cold download. Nothing found is `PRSKL0004`, naming the fix; `PurviewSkillsCommand` +overrides the launcher entirely and skips detection. A dry run with nothing on `PATH` prints the +configured runner's command instead of failing. + +## Always from the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs the CLI **from the repository root, always**. The root is resolved by this chain: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — when the *user* set it +3. the **marker walk** (below) +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. `GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY` or `CI_PROJECT_DIR` +6. the git source root SourceLink reports + +There is deliberately **no fall back to the project directory**: that would scatter a lockfile and an +agent directory through the tree, one per project. If the root cannot be resolved, the target reports +`PRSKL0003` and names `PurviewSkillsInstallRoot` rather than guessing. + +### The marker walk + +`PurviewRepositoryRootMarkers` lists what identifies a repository root, most authoritative first: + +```text +.git/ ; .github/ ; .gitattributes ; LICENSE.md ; AGENTS.md ; .agents/ ; CLAUDE.md ; .claude/ ; package.json +``` + +A trailing slash marks a directory — and still matches a *file* of that name, because `.git` is a +directory in a clone but a file in a worktree or submodule. MSBuild's own `GetDirectoryNameOfFileAbove` +matches files only, which is why this is a task rather than an intrinsic: it finds nothing for `.git/`, +`.github/`, `.agents/` or `.claude/`. + +Resolution runs in two passes, because neither pure priority nor pure proximity is right alone: + +1. **The first marker in the list is definitive** and is searched all the way up. `.git/` *is* the + repository root by definition, so a project inside a monorepo package resolves to the repository + rather than to the nearer folder holding a `package.json`. +2. **Otherwise the nearest ancestor** holding any marker wins, with list order breaking ties inside that + one directory. + +Pass 2 is nearest-first on purpose. Searching every remaining marker to the top would let a stray +`.claude/` in a developer's home directory outrank the `package.json` at the real repository root. For +the same reason the home directory and everything above it are **never** candidates. A repository +*inside* your home directory is unaffected, because it sits below it. + +| Project at `/repo/sub/src/proj` | Resolves to | +| -- | -- | +| `/repo/.git/` + `/repo/sub/package.json` | `/repo` — the definitive marker wins from a distance | +| `/repo/.github/` + `/repo/sub/package.json` | `/repo/sub` — nearest wins once `.git/` is absent | +| `/repo/LICENSE.md` + `/repo/package.json` | `/repo`, reported as `LICENSE.md` (higher in the list) | +| no marker below the home directory | unresolved — `PRSKL0003`, never `~` | + +## Configuration + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewSkillsSource` | *(none)* | The source `InstallPurviewSkills` adds (`owner/repo`, optionally `#ref`); without it, install restores the lockfile. | +| `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the CLI's lockfile is read from. | +| `PurviewSkillsAgents` | *(empty — the CLI auto-detects)* | Agents to install to; when set, the only agent skill directories `CheckPurviewSkills` allows. | +| `PurviewSkillsDryRun` | *(unset, which is treated as false)* | Print the install and update commands without running them. | +| `PurviewSkillsRunner` | `bun` | Package runner used to launch the CLI. | +| `PurviewSkillsRunnerCandidates` | `bun;npx;bunx;pnpm;yarn;deno` | Probed in order after `PurviewSkillsRunner`. | +| `PurviewSkillsPackage` | `skills@1` | Package spec the launcher pins to. | +| `PurviewSkillsCommand` | *(detected)* | Overrides the launcher entirely and skips runner detection. | +| `PurviewSkillsInstallRoot` | *(resolved)* | Overrides the repository root every skill target runs from. | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers that identify a repository root; the first is definitive. | + +## Diagnostics + +| Code | Meaning | +| -- | -- | +| `PRSKL0003` | The repository root could not be resolved, so there is nowhere correct to install | +| `PRSKL0004` | No package runner was found on `PATH` | +| `PRSKL0005` | An authored skill is malformed | +| `PRSKL0007` | Nothing to install or update: no `PurviewSkillsSource`, and no `skills-lock.json` | +| `PRSKL0008` | Re-adding a lockfile source failed | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | + +Retired: `PRSKL0001`, `PRSKL0002`, `PRSKL0010`–`PRSKL0014` and `PRSKL0017`, with the package-to-skill +mapping and the `ListPurviewSkills`, `RecommendPurviewSkills` and `VerifyPurviewSkills` targets they +belonged to; and `PRSKL0006`, with `PurviewSkillsUpdateMode`. + +## Supported CLI commands + +The skills CLI v1 commands, and the role each plays here: + +| Command | Purpose | Role here | +| -- | -- | -- | +| `bun x skills add ` | Install skills | What `InstallPurviewSkills` and `UpdatePurviewSkills` run; accepts `owner/repo#` | +| `bun x skills experimental_install` | Restore from `skills-lock.json` | What `InstallPurviewSkills` runs without a source; experimental in the CLI | +| `bun x skills list` | List installed skills | Diagnostics, run directly | +| `bun x skills find [query]` | Search for skills | Discovery, run directly | +| `bun x skills update` | Update to latest | Not used — an update is a re-add, which also picks up new skills | +| `bun x skills remove` | Remove skills | Run directly | +| `bun x skills use @` | Use without installing | One-off guidance | +| `bun x skills init [name]` | Scaffold a skill | Authoring | + +## See also + +- [Configuration Reference](../Configuration-Reference.md) +- [Justfile](Justfile.md) diff --git a/docs/wiki/tooling/Justfile.md b/docs/wiki/tooling/Justfile.md new file mode 100644 index 0000000..e095934 --- /dev/null +++ b/docs/wiki/tooling/Justfile.md @@ -0,0 +1,70 @@ +# Justfile + +A repository that uses Purview.BuildSdk keeps its shared `just` recipes in modules under `.build/just/`, +imported through one barrel, so its own `Justfile` holds only what is specific to it. The modules are +the [`purview-buildsdk-justfile`](../../../skills/purview-buildsdk-justfile/SKILL.md) skill's +`assets/just/`, copied verbatim, and that skill is how an agent adds or migrates them. + +## Layout + +```text +Justfile settings, one import, the repository's variables and own recipes +.build/just/ + index.just the barrel; optional modules are imported with `import?` + env.just exported telemetry opt-outs and DOTNET_CLI_ENABLEAOT + version.just version, bump + bump-version.ts what `just bump` runs (Bun) + dotnet.just ensure-tools, restore, build, clean, test, pack + utilities.just lint-check, lint-fix, vs, scrub + purview-build.just pipeline-pr, pipeline-build, pipeline-tests + skills.just skills-add, -install, -update, -check, -fix + purview-build-nuget.just optional: pipeline-pack-validate, pipeline-local-release, pipeline-release + docs.just optional: docs, docs-install, docs-check (a pm-docs-site project site) +``` + +The root `Justfile` sets `allow-duplicate-variables` and `allow-duplicate-recipes` and imports +`.build/just/index.just` — nothing else. Every module gives its variables a default, and a definition +in the root `Justfile` replaces it: `solution_file := "src/Acme.slnx"`. An optional module is switched +on by copying its file into `.build/just/` and off by deleting it; the barrel never changes. + +## Variables + +| Variable | Default | Used by | +| -- | -- | -- | +| `solution_file` | `.` | every .NET, utility and skill recipe | +| `build_configuration` | `Debug` | `build`, `clean`, `test`, `pack` | +| `artifacts_folder` | `./artifacts` | `pack`, `scrub` | +| `test_filter` | `/*/*/*/*/` | `test` with no argument | +| `version_file` | `package.json` | `version`, `bump` | +| `csharpier` | `dotnet csharpier` | `lint-check`, `lint-fix` | +| `pipeline_tool` | `dotnet purview-build` | the `pipeline-*` recipes | +| `skills_project` | `solution_file` | the `skills-*` recipes | +| `docs_site` | `src/docs-site` | the `docs*` recipes | + +## Version + +`just version` prints the version in `package.json`, read by `just` itself. `just bump major`, +`minor` or `patch` increments that part and resets the parts after it (`2.0.1` → `3.0.0`, `2.1.0`, +`2.0.2`), drops any pre-release suffix, and rewrites only the `"version"` value. `--dry-run` writes +nothing, and any other part is rejected. Afterwards it runs the `_after-bump` hook, which does nothing +unless the root `Justfile` overrides it — this repository's runs `version-sync`. + +## Environment + +`env.just` exports, to every process a recipe starts, the telemetry opt-outs for the .NET CLI, +MSBuild, Aspire, the Azure SDKs, PowerShell, Git Credential Manager, Next.js, Astro, Yarn and Claude +Code, plus `DO_NOT_TRACK`, `DOTNET_NOLOGO` and `DOTNET_CLI_ENABLEAOT`. Each is a default: a value +already in the environment wins. + +The MSBuild server (`DOTNET_CLI_USE_MSBUILD_SERVER`) is deliberately left to the SDK's default. A +shared server keeps the NuGet settings it first read, so a later build under a different `nuget.config` +resolves SDKs from the wrong sources and fails with `MSB4242`. This repository's package-consumption +tests do exactly that. Multithreaded MSBuild (`-mt`) comes from the `Directory.Build.rsp` the SDK +bootstraps, not from the environment. + +## This repository + +The root `Justfile` here keeps `version-sync`, `version-sync-check`, `properties-check`, `test-linux` +and `skills-validate`, and overrides `_after-bump`. `JustModuleParityTests` fails if `.build/just/` +differs from the skill's assets, holds a file the skill does not ship, or if the root `Justfile` +imports anything but the barrel or redefines a module's recipe. diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 306d6a7..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,21 +0,0 @@ -site_name: Purview Build SDK -site_description: Developer documentation for Purview Build SDK -repo_url: https://github.com/purview-dev/build-sdk -edit_uri: edit/main/docs/wiki/ -docs_dir: docs/wiki - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/package.json b/package.json index 0d62d59..230d914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build-sdk", - "version": "2.0.1", + "version": "2.1.0", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/skills/purview-buildsdk-adoption/SKILL.md b/skills/purview-buildsdk-adoption/SKILL.md index 9f25048..07be90a 100644 --- a/skills/purview-buildsdk-adoption/SKILL.md +++ b/skills/purview-buildsdk-adoption/SKILL.md @@ -49,7 +49,7 @@ where noted: | What | Where | Notes | | -- | -- | -- | | `NamespacePrefix` | `Directory.Build.props`, above the import | Required — the build fails without it unless `DisableNamespacePrefixCheck=true`. | -| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version, e.g. `2.0.1`. The SDK bootstraps `global.json` when it is missing and writes the version it was packed as (`PurviewBuildSdkVersionForGlobalJson`). | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version, e.g. `2.0.1`. The SDK bootstraps `global.json` when it is missing and writes `PurviewBuildSdkVersionForGlobalJson` — the restored package version, else the version it was packed as. | | Product version | repo-root `package.json` → `version` | Drives `Version` and `PackageVersion`. `UsePackageJsonVersion` / `RootPackageJson` are pre-import. | | Package versions | `Directory.Packages.props` | CPM is on; a `PackageVersion` is a minimum, not an exact pin. | @@ -70,13 +70,17 @@ This is the single most common adoption failure. Set before the import: - `NamespacePrefix` -- `UsePackageJsonVersion`, `RootPackageJson` +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot` - `EnableAssemblyNameGeneration` -- `AutoIncludeUsings` -- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` -- any repo-wide testing-framework selection you want every project to inherit +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` — even for a single project; a + value in the `.csproj` is too late and the defaults (`TUnit`, `TUnitMocks`, `Bogus`) stay +- `PurviewTestContextNoWarn`, `DisablePurviewTestContextRuleSet` +- `DisablePurviewBlazorWebAssemblyDefaults` +- `PurviewTargetFrameworks`, when redefining a framework set -Everything else is read in `Sdk.targets` and can be set in a project file. +Most other properties are read in `Sdk.targets`, or only after the project body, and can be set in a +project file — `PurviewSharedTestingOutputType` and `AutoIncludeUsings` among them. `purview-buildsdk-configuration` lists +which is which. When a property appears to have no effect, confirm what MSBuild actually resolved before changing anything else: @@ -90,11 +94,14 @@ dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace Versions come from `package.json`. Resolution order: 1. An explicit `RootPackageJson`. -2. CI workspace variables. -3. The `.git` root. -4. A nearby `package.json`. +2. An explicit `RepoRoot`, else CI workspace variables (`GITHUB_WORKSPACE`, + `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`). +3. The nearest `.git` above the project. +4. The nearest `package.json` above the project. -On success both `Version` and `PackageVersion` are set from the `version` field. +The `package.json` at that root is read, and on success both `Version` and `PackageVersion` are set +from its `version` field. A root that is found but has no `package.json`, or a `package.json` with no +`version`, already fails the build. If the version looks wrong, do not guess at the discovery — make it fail loudly instead: @@ -102,8 +109,8 @@ If the version looks wrong, do not guess at the discovery — make it fail loudl Strict ``` -`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping -it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. +`Strict` also fails the build when no repository root can be discovered at all, rather than silently +skipping detection and leaving the default `0.0.1`. Set `VersionDetectionLogEnabled=true` to see what it found. ## 4. Naming and placement @@ -124,7 +131,7 @@ cleaning them up are separate targets a person runs by name — `InstallPurviewS repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. -If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +If the repository uses `just`, add recipes for them — see `purview-buildsdk-justfile`. For what each target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. ## 6. Target frameworks @@ -138,9 +145,9 @@ dotnet msbuild -getProperty:TargetFramework -getProperty:TargetFramewo To set it: -- **Declare it explicitly** — `TargetFramework` (one) or `TargetFrameworks` (many). It must be declared - **unconditionally**: the SDK reads it from the project XML before the body is evaluated, so a - `Condition` on it is not seen (`PurviewConditionedProjectDeclaration`). +- **Declare it explicitly** — `TargetFramework` (one) or `TargetFrameworks` (many). An explicit value + always wins over every set, wherever it is set (the project, with or without a `Condition`, a file it + imports, or `Directory.Build.props`) — but not in `Directory.Build.targets`, which is too late. - **Select a curated set** — `Supported` in `Directory.Build.props` or the `.csproj` (`Current`, `Latest`, `Supported`, `Broad`, `All`), so a lifecycle change is one SDK bump rather than an edit in every project. @@ -154,7 +161,8 @@ A Roslyn component targets `netstandard2.0` and ignores the sets. Full detail in The SDK adds a SourceLink provider package (`SourceLinkPackageName`, default `Microsoft.SourceLink.GitHub`) to every non-test project, and `PublishRepositoryUrl=true` writes the -repository URL and commit into the packed `.nuspec`. Match the repository host **before the SDK import**: +repository URL and commit into the packed `.nuspec`. Match the repository host in +`Directory.Build.props`, so every project gets the same provider: | Host | Package | | -- | -- | diff --git a/skills/purview-buildsdk-configuration/SKILL.md b/skills/purview-buildsdk-configuration/SKILL.md index a83cb4c..767a81a 100644 --- a/skills/purview-buildsdk-configuration/SKILL.md +++ b/skills/purview-buildsdk-configuration/SKILL.md @@ -25,17 +25,17 @@ Pre-import properties include: - `NamespacePrefix` - `UsePackageJsonVersion`, `RootPackageJson` - `EnableAssemblyNameGeneration` -- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` -- `AutoIncludeUsings` +- `PurviewTestContextNoWarn`, `DisablePurviewTestContextRuleSet` - `DisablePurviewBlazorWebAssemblyDefaults` -- repo-wide testing-framework selection you want every project to inherit +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` — even for a single project -A target framework is a special case: the SDK reads `TargetFramework`/`TargetFrameworks` and -`PurviewTargetFrameworkSet` from the **project XML**, so they can live in the project or in -`Directory.Build.props`, but must be declared **unconditionally** either way — see -[Setting the target framework(s)](#setting-the-target-frameworks). +A target framework is a special case. An explicit `TargetFramework`/`TargetFrameworks` always wins, +wherever it is set except `Directory.Build.targets`. `PurviewTargetFrameworkSet` is read from the +**project XML**, so it can live in the project or in `Directory.Build.props` but must be declared +**unconditionally** — see [Setting the target framework(s)](#setting-the-target-frameworks). -Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +Everything else is read in `Sdk.targets`, or only after the project body, and can be set in a project +file — `PurviewSharedTestingOutputType` and `AutoIncludeUsings` among them. Prefer repo-wide defaults in `Directory.Build.props` unless the setting is deliberately project-specific. ## Required settings and versions @@ -56,10 +56,11 @@ A project that declares neither `TargetFramework` nor `TargetFrameworks` gets th `net10.0;net11.0` (`Current`). Pick one of three routes, narrowest first: 1. **Explicit declaration** — `net10.0` for one target, - `net10.0;net11.0` for many. Declare it **unconditionally**: the - SDK reads it from the project XML before the body is evaluated, so a `Condition` on it is not seen - (and is reported as `PurviewConditionedProjectDeclaration`). A project declaring `TargetFrameworks` - keeps `TargetFramework` empty. + `net10.0;net11.0` for many. An explicit value **always wins** + over every set, wherever it is set — the project (with or without a `Condition`), a file it + imports, or `Directory.Build.props` before or after the import. Only `Directory.Build.targets` is + too late, because the .NET SDK has already decided whether to multi-target. A project declaring + `TargetFrameworks` keeps `TargetFramework` empty. 2. **A curated set** — `Supported` in `Directory.Build.props` (repository-wide) or the `.csproj`. Sets: `Current` (default), `Latest` (`net11.0`), `Supported` (`net10.0;net11.0`), `Broad` (`net8.0;net9.0;net10.0`), `All` @@ -84,16 +85,16 @@ one is reported as `PurviewTargetFrameworkSetIgnored`. The full table is in | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | -| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `DisableSourceLink` | `false` | Stops the SDK adding the SourceLink provider package to any project | | `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | | `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+: `0` off (default), `1` preview syntax, `2` updated rules (`updated-memory-safety-rules`, opt-in — an experimental compiler feature) | -| `PurviewRuntimeAsync` | `false` | Opts a `net11.0`+ target into `runtime-async=on`; `UseRuntimeAsync=true` also opts in | +| `PurviewRuntimeAsync` | *(unset, treated as false)* | Opts a `net11.0`+ target into `runtime-async=on`; `UseRuntimeAsync=true` also opts in | | `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | | `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | -| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt`; unset, a Blazor WebAssembly client writes `-mt:false` (`PRBLZ0002`) | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -153,7 +154,10 @@ The SDK bootstraps three repository-wide files from the package: `.editorconfig` - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` - `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a - non-`-mt` build) + non-`-mt` build). A repository with a Blazor WebAssembly client gets `-mt:false` instead, because a + `net11.0` client cannot build under `-mt`; a client built under `-mt` anyway fails with `PRBLZ0002`. + The WebAssembly SDK's own error for this is `MSB4060` on `ReadWasmProps`, and it does not mention the + response file Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -175,14 +179,14 @@ on the command line, and a repository-wide choice belongs in `Directory.Build.pr | `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write -`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-justfile`. ## SourceLink Every non-test project gets a SourceLink provider package — `SourceLinkPackageName`, default `Microsoft.SourceLink.GitHub`, added with `PrivateAssets="all"` so a debugger can step into a consumed package's source and the `.nuspec` records the repository URL and commit. A repository on another host -selects the matching package **before the SDK import**: +selects the matching package in `Directory.Build.props`, so every project agrees: | Host | Provider package | | -- | -- | @@ -193,15 +197,14 @@ selects the matching package **before the SDK import**: | Bitbucket | `Microsoft.SourceLink.Bitbucket.Git` | Because CPM is on, the chosen provider also needs a `PackageVersion` in `Directory.Packages.props`. Use -`DisableSourceLink=true` (pre-import) to opt out. The host matrix, the on-premises Azure DevOps Server +`DisableSourceLink=true` (in `Directory.Build.props`, or one project) to opt out. The host matrix, the on-premises Azure DevOps Server host item, the CPM requirement and the self-hosted case are in [references/sourcelink.md](references/sourcelink.md). -## Guidance when changing SDK configuration +## Guidance when changing configuration -1. Preserve existing defaults unless the task explicitly changes product behaviour. -2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a - property documented in one place and absent from another is the failure mode this repository - keeps hitting. -3. A new property that affects import-time behaviour must say so in its documentation. -4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. +1. Preserve existing defaults unless the task explicitly changes behaviour, and do not restate an SDK + default in a project file. +2. Confirm a change with `dotnet msbuild -getProperty:` rather than reasoning about it. +3. For a package that ships its **own** MSBuild properties — adding one, renaming it, documenting it in + a `.buildschema.json` sidecar, or exporting it to analyzers — use `purview-buildsdk-msbuild-properties`. diff --git a/skills/purview-buildsdk-configuration/references/properties.md b/skills/purview-buildsdk-configuration/references/properties.md index 01b49f8..8efd3db 100644 --- a/skills/purview-buildsdk-configuration/references/properties.md +++ b/skills/purview-buildsdk-configuration/references/properties.md @@ -12,22 +12,23 @@ consuming project supplies nothing. | `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | | `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | | `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | -| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableGenerateAssemblyInfoClass` | `false` (forced `true` for test and shared-testing projects) | Drops the generated `AssemblyInfo` | | `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | | `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | -| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Can be set in a project file | ## Target framework and language | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. Declare it (or `TargetFrameworks`) **unconditionally** — the SDK reads it from the project XML, so a `Condition` on it is not seen. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. An explicit value (or `TargetFrameworks`) always wins over every set, wherever it is set — with or without a `Condition` — except `Directory.Build.targets`, which is too late. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | | `PurviewTargetFrameworkSet` | `Current` | Named set resolved when the project declares no TFM: `Current`, `Latest`, `Supported`, `Broad` or `All`. An unknown name fails with `PurviewInvalidTargetFrameworkSet`. A single-entry set resolves to `TargetFramework` rather than paying for an outer build. Declare unconditionally | | `PurviewTargetFrameworksCurrent` | `net10.0;net11.0` | Redefines the `Current` set (the default). Set before the SDK import | | `PurviewTargetFrameworksLatest` | `net11.0` | Redefines the `Latest` set | | `PurviewTargetFrameworksSupported` | `net10.0;net11.0` | Redefines the `Supported` set | | `PurviewTargetFrameworksBroad` | `net8.0;net9.0;net10.0` | Redefines the `Broad` set | | `PurviewTargetFrameworksAll` | `net8.0;net9.0;net10.0;net11.0` | Redefines the `All` set | +| `TreatWarningsAsErrors` | `true` | Every project: compiler, analyzer and NuGet warnings fail the build (MSBuild `` tasks do not). `false` in a project or `Directory.Build.props` opts out. A packable Roslyn component still fails its pack without it (`PRSGD0003`) | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | @@ -52,10 +53,10 @@ they target `netstandard2.0`; selecting a set on one is reported as `PurviewTarg | Property | Default | Behaviour | | -- | -- | -- | | `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off (default), `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Tier `2` is opt-in — the updated rules are an experimental compiler feature. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | -| `PurviewRuntimeAsync` | `false` | Set to `true` for `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off | -| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `PurviewRuntimeAsync` | *(unset, treated as false)* | Set to `true` for `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false`. Unset, a Blazor WebAssembly client writes `-mt:false` for the repository, and a client built under `-mt` anyway fails with `PRBLZ0002`; an explicit value always wins | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | -| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.ClientShared`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` (or `ClientShared`/`Components`) name. Explicit value wins | | `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | | `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | | `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | @@ -71,7 +72,7 @@ they target `netstandard2.0`; selecting a set on one is reported as `PurviewTarg | `EnableStaticNativeInstrumentation` | `false` for test projects | | | `EnableDynamicNativeInstrumentation` | `false` for test projects | | | `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | -| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` (in the project or `Directory.Build.props`) to keep the package-driven shape | ## Packaging @@ -87,8 +88,9 @@ Packable-project defaults, applied only when the project supplied nothing: in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. -When the repository root is discoverable, the root `README.md` is packed automatically and -registered as `PackageReadmeFile`, unless one is configured already. +Unless `PackageReadmeFile` is configured, a README is packed and registered automatically: the project's +own `README.md` (beside its `.csproj`) wins, else the repository-root one. A README-named file already +being packed, such as an `Sdk/README.md`, is used instead. Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a solution-wide pack skips them silently. @@ -102,11 +104,12 @@ forces SourceLink's CI-mode dirty-repository checks. | -- | -- | -- | | `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | | `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package, added with `PrivateAssets=all` to every non-test project. Set before the SDK import to match the host — `Microsoft.SourceLink.AzureRepos.Git` (Azure DevOps Services), `Microsoft.SourceLink.AzureDevOpsServer.Git` (on-premises ADO Server / TFS), `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git` | -| `DisableSourceLink` | `false` | `true` (before the import) stops the SDK adding the package at all. An empty `SourceLinkPackageName` does **not** opt out — the GitHub default fills it | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package, added with `PrivateAssets=all` to every non-test project. Set it in `Directory.Build.props` to match the host — `Microsoft.SourceLink.AzureRepos.Git` (Azure DevOps Services), `Microsoft.SourceLink.AzureDevOpsServer.Git` (on-premises ADO Server / TFS), `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git` | +| `DisableSourceLink` | `false` | `true` stops the SDK adding the package at all. An empty `SourceLinkPackageName` does **not** opt out — the GitHub default fills it | | `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | -Provider selection is read during `Sdk.props`, so it must be pre-import. See +Both are read only on the package reference the SDK adds, which MSBuild evaluates after the project body, +so a `.csproj` value works too; `Directory.Build.props` keeps every project on one provider. See [sourcelink.md](sourcelink.md) for the host matrix, Azure DevOps specifics, the CPM `PackageVersion` requirement and self-hosted hosts. @@ -119,11 +122,11 @@ requirement and self-hosted hosts. | `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | | `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | | `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | -| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningsAsErrors` | `true` on a `PurviewBrowserSafe` library, otherwise unset | `false` hands the trim and AOT diagnostics back to `TreatWarningsAsErrors` (`true` by default); `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | | `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | | `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | -| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, otherwise unset | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | `PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a reference was judged browser-safe when it cannot say so itself. @@ -133,7 +136,7 @@ reference was judged browser-safe when it cannot say so itself. | Property | Default | Behaviour | | -- | -- | -- | | `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | -| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read. A relative path resolves from the project directory; in `Directory.Build.props` use `$(MSBuildThisFileDirectory)package.json` | | `EnableVersionDetectionCache` | `true` | | | `VersionDetectionCacheFile` | *(derived)* | | | `VersionDetectionLogEnabled` | `false` | Logs the detected version | @@ -150,7 +153,7 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | | `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | -| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false`; unset, a WebAssembly client writes `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -183,7 +186,8 @@ Read the exported property rather than re-deriving SDK behaviour — it is the S correct when the rules change. To make a new property visible, add a `CompilerVisibleProperty` item (with a ``) in `Sdk.props`; nothing else exposes an MSBuild property to the compiler. -Exported: +Among the exported properties (the full list, with descriptions, is the *Compiler-visible SDK properties* +table in the [Configuration Reference](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Configuration-Reference.md#compiler-visible-sdk-properties)): - `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` - `NamespacePrefix`, `DisableNamespacePrefixCheck` diff --git a/skills/purview-buildsdk-configuration/references/sourcelink.md b/skills/purview-buildsdk-configuration/references/sourcelink.md index 062d958..aafa623 100644 --- a/skills/purview-buildsdk-configuration/references/sourcelink.md +++ b/skills/purview-buildsdk-configuration/references/sourcelink.md @@ -9,12 +9,13 @@ because their symbols are never published. | Property | Default | Behaviour | | -- | -- | -- | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package. Set before the SDK import | -| `DisableSourceLink` | `false` | `true` (before the import) stops the package being added at all | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package | +| `DisableSourceLink` | `false` | `true` stops the package being added at all | | `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | -All three are read during `Sdk.props`, so they must be set **before the SDK import** — in -`Directory.Build.props`, above the ``. A value in the `.csproj` body is too late. An **empty** +Set `SourceLinkPackageName` and `DisableSourceLink` in `Directory.Build.props`, so every project agrees. +A `.csproj` value also works: the SDK reads both only on the package reference it adds, and MSBuild +evaluates that after the project body. `EnableSourceLink` belongs to the provider package. An **empty** `SourceLinkPackageName` does not disable SourceLink: the SDK fills it with the GitHub default. ## Choosing a provider for the host @@ -62,18 +63,18 @@ provider that is selected but not declared fails the restore with `NU1010`. ## Continuous integration -`ContinuousIntegrationBuild` is set to `true` only when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is present +`ContinuousIntegrationBuild` is set to `true` only when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is `true` (`TF_BUILD` is Azure Pipelines). A local build is deliberately **not** treated as CI, because SourceLink's CI-mode dirty-repository checks would otherwise fail a local `TreatWarningsAsErrors` build. ## Opting out, and self-hosted hosts -`DisableSourceLink=true` (before the import) stops the SDK adding the package. For a host with no +`DisableSourceLink=true` stops the SDK adding the package. For a host with no official provider, bring a source-control package (`Microsoft.Build.Tasks.Git`) and a provider package in yourself and set `DisableSourceLink=true`, so the GitHub default is not added alongside them. ## Why it matters beyond debugging -`PublishRepositoryUrl=true` writes `` into the `.nuspec`. Purview's -agent-skill tooling reads that to pin a package to the commit it was built from, and reports a package -with no git identity as `PRSKL0002`. +`PublishRepositoryUrl=true` writes `` into the `.nuspec`, so anyone +holding the package can find the exact commit it was built from — the commit to pin, for example, when +installing that repository's skills with `bun x skills add owner/repo#`. diff --git a/skills/purview-buildsdk-docs-sync/SKILL.md b/skills/purview-buildsdk-docs-sync/SKILL.md new file mode 100644 index 0000000..9661abc --- /dev/null +++ b/skills/purview-buildsdk-docs-sync/SKILL.md @@ -0,0 +1,145 @@ +--- +name: purview-buildsdk-docs-sync +description: Bring a Purview.BuildSdk repository's written surfaces back in line with its code - the authored skills (skills/*/SKILL.md and references/), the agent instructions (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md), the READMEs (repository root, each project's README.md, a project's Sdk/README.md, which is what its package ships) and the docs/wiki/ pages - and keep the wiki hierarchical, one topic folder per area, with _Sidebar.md and Home.md in step. Use after a feature, property, recipe, diagnostic or project is added, renamed or removed; when docs or skills may have drifted from the code; when reorganising or adding wiki pages; or when asked to audit or "sync the docs". Every statement is checked against the code, never against another document. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Keeping skills, agent instructions, READMEs and the wiki true + +A repository describes itself in four places, read by different people: + +| Surface | Read by | Lives in | +| -- | -- | -- | +| Authored skills | agents in *other* repositories, after `npx skills add` | `skills//SKILL.md`, `skills//references/*.md` | +| Agent instructions | agents working *in* this repository | `AGENTS.md`; `CLAUDE.md` and `.github/copilot-instructions.md` are thin wrappers that point at it | +| READMEs | GitHub visitors, and NuGet users of each package | the repository root, each project folder, a project's `Sdk/` folder | +| Wiki | readers of the documentation site | `docs/wiki/` | + +They drift independently, and nothing fails when they do: the reader simply follows a document that +describes last release's code. **The code is the only source of truth.** Never correct one document +from another — two documents agreeing proves nothing if both are stale. + +## The one rule + +**Every claim is checked against the code before it is kept, written or changed.** A claim is +anything a reader could act on: a property name and its default, a recipe, a command, a diagnostic +id, a file path, a package id, a target framework, a default behaviour, a version. Look each one up: +read the `.props`/`.targets`, the `Justfile` and its modules, the analyzer's descriptor, the +`.csproj`, the test that pins the behaviour. A claim you cannot find in the code is **removed or +marked as a question for the user** — never kept on trust, never guessed. + +How to check each kind of claim: [references/truth-audit.md](references/truth-audit.md). + +## Which README is which + +What a package ships as its README decides which file a NuGet user reads, so work it out from the +build, not from the file names. With Purview.BuildSdk, for a packable project with no explicit +`PackageReadmeFile`: + +1. the project's own `README.md`, beside its `.csproj`, wins; +2. otherwise the repository-root `README.md` is packed; +3. a file under the project's `Sdk/` folder named `*.md` is packed to the package root by + `PurviewAutoSdkPack`, so `Sdk/README.md` *is* the package README when the project packs `Sdk/`. + +So in a repository with several packages, each package's README is the per-project one, and the root +`README.md` describes the repository as a whole. A project README covers only what that package +does, how to install it and its own settings; it links to the wiki for the rest rather than copying +it. Check which file each project actually packs (`dotnet pack`, then list the `.nupkg`) before +editing one, and say in the root README which per-project READMEs exist. + +## The wiki is hierarchical + +`docs/wiki/` is one folder per topic area, plus a few root pages. Two index files tie it together: + +- `_Sidebar.md` — every page exactly once, grouped into sections (`- **Section**`), in reading order. + The documentation site reads its links in document order for page order, and does not publish it. +- `Home.md` — the browsing index: a short intro, a "How this wiki is organised" table, then one + `## Section` heading per sidebar section, each with a `| Page | What it answers |` table. + +The rules, the published-URL consequences, and the test that enforces them: +[references/wiki-structure.md](references/wiki-structure.md). The short form: + +- a page lives at the wiki root **only** when the site depends on its URL: the landing page + (`docs.rootPage`) and any page the site's project catalogue deep-links (`docsPage`). Everything else + is in exactly one topic folder, never deeper; +- folder and page names are letters, digits and single dashes (`configuration/Property-Schema.md`); +- each page opens with exactly one `# ` title, then a one-paragraph description of what it answers; +- links are relative to the file they are written in (`../packaging/SourceLink.md` from a folder page); +- moving a page changes its published URL — record the old and new slugs so the site can redirect. + +## The sweep + +Run it as a sweep, not an edit: a change that lands in one surface and stops there is exactly the +drift this skill exists to prevent. + +1. **Inventory the change.** From the diff (or, for an audit, the code): what was added, renamed, + re-defaulted or removed — properties, targets, recipes, diagnostics, projects, packages, skills, + files. Each item is a row in the sweep. +2. **Find every mention.** For each item, search all four surfaces, including old names: + `git grep -n -e OldName -e NewName -- '*.md'`. Also search `CHANGELOG.md`, but never rewrite + released entries — they are history. +3. **Correct each mention against the code** (the one rule). Prefer deleting a duplicated explanation + and linking to its one home over keeping two copies in step. +4. **Skills** + - Update the `description` so the skill *fires* on the new question, and keep it under 1024 + characters. + - Keep `SKILL.md` under 500 lines, with detail in `references/`. + - A skill describes what a **consumer** does. Guidance about working in this repository belongs in + `AGENTS.md`, or in a repository-local skill under `.agents/skills/`, which is not published. + - A new skill is named `--`, and its folder name matches its `name`. +5. **Agent instructions.** + - `AGENTS.md` carries the skill table (one row per authored skill, linking its authored path + `./skills//SKILL.md`, never `.agents/skills/`), the repository map, the canonical commands + and the invariants a contributor must not break. + - The wrappers stay one-line pointers to it. +6. **READMEs.** + - The root README covers what the repository ships, the quick start, the feature summary and where + the docs are. + - Each per-project README covers its own package. + - Long reference tables belong in the wiki. Keep only those the tests hold the README to. +7. **Wiki.** + - Update the page that owns the topic. + - A new page goes in its topic's folder and gets added to `_Sidebar.md` (once, in reading order) + and to that section's table in `Home.md`. + - A new topic area gets a folder, a sidebar section and a `Home.md` heading, all with the same + name. +8. **Changelog.** Add one entry per user-visible change: `### Documentation` for a docs-only fix, + naming what was wrong. It goes under the heading for the version in `package.json` + (`## [x.y.z] - `, created when a bump moves the version), **not** under `[Unreleased]`. A + merge to `main` publishes that version, so an entry parked under `[Unreleased]` reads as unreleased + until it is already live, and the changelog is out of date the moment it ships. `[Unreleased]` is + only for a pre-release version (`x.y.z-alpha.1`, `-beta`, `-rc`), whose changes genuinely have not + shipped as a release yet. +9. **Verify** (below), and state the commands you ran. + +## Verify + +Each check proves something different, so run all that the repository has: + +```bash +git grep -n -e '' -- '*.md' # nothing stale left +just skills-validate # PRSKL0005: every skill well formed, every references/ link resolves +just test # the repository's doc tests: wiki structure, skill rules, property tables +``` + +- **The wiki test:** if the repository has none, add one (see + [references/wiki-structure.md](references/wiki-structure.md#the-test)). Without it, every rule + above is a convention nobody checks. +- **Links outside the wiki:** for every link a README or skill makes into `docs/wiki/`, open the + target and confirm the page and the `#anchor` exist. +- **Skill references:** follow each relative link once. + +## Guardrails + +- **Do not change behaviour to match a document.** When the code and the docs disagree, the docs are + wrong — unless the user says the code is. Report a disagreement that looks like a code bug rather + than documenting the bug as intended. +- **Do not invent.** No undocumented defaults, no example output you did not run, no version numbers + from memory. +- **Do not move a root page** the site deep-links without telling the user. It breaks inbound links + outside this repository. +- **Do not rewrite released changelog entries,** and do not touch generated files. +- Keep the sweep to documentation: a refactor found along the way is a separate change. diff --git a/skills/purview-buildsdk-docs-sync/references/truth-audit.md b/skills/purview-buildsdk-docs-sync/references/truth-audit.md new file mode 100644 index 0000000..3da13b7 --- /dev/null +++ b/skills/purview-buildsdk-docs-sync/references/truth-audit.md @@ -0,0 +1,41 @@ +# Checking a claim against the code + +For each kind of statement a document makes, where the truth lives and how to look it up. Search with +`git grep -n`, which only reads tracked files, and read the definition itself rather than a comment +that describes it. + +| Claim | The truth | How to check | +| -- | -- | -- | +| An MSBuild property exists, and its default | the `.props`/`.targets` that define it (`Sdk/Props/*.props`, `Sdk/Sdk.props`, `Sdk/Sdk.targets`, `Sdk/Targets/*.targets`), and the `.buildschema.json` sidecar | `git grep -n ' -getProperty:Name` | +| A property must be set before the import | whether `Sdk.props`, or a file it imports, *reads* it | a read in a value or `Condition` in `Sdk.props`'s import graph, other than its own `== ''` guard, makes it pre-import | +| An analyzer reads `build_property.X` | a `CompilerVisibleProperty Include="X"` item, and the analyzer source | `git grep -n 'CompilerVisibleProperty' -- '*.props'` and `git grep -n 'build_property.X'` | +| A diagnostic id, its severity and its meaning | the `DiagnosticDescriptor` (analyzers) or the `Code="..."` on an MSBuild `Error`/`Warning`/`Message` (targets) | `git grep -n 'PRSKL0012'`. The severity is the descriptor's default or the task's `Importance`, not the docs table | +| A target exists and what it runs | the ``, its `DependsOnTargets`, `BeforeTargets`/`AfterTargets` | read the target; "never runs during a build" means it has no hook *and* nothing hooks it | +| A `just` recipe and what it does | the `Justfile` and every file it imports | `just --list` for names, `just --dry-run ` for the exact command — never read a recipe name off another document | +| A command line works | running it | run it, or `--dry-run`/`-p:...DryRun=true` when it changes things; do not quote output you did not see | +| A file or folder path | the file system | `git ls-files `, which also proves it is tracked rather than generated | +| A package id, and what it contains | the `.csproj` (`PackageId`, `IsPackable`) and the packed `.nupkg` | `dotnet pack`, then list the archive; pack validation config (for example `.config/purview-build.json` `RequiredContent`) lists the expected files | +| A target framework or framework set | the `.props` defining the sets, and the project's evaluated value | `dotnet msbuild -getProperty:TargetFrameworks` | +| A version | `package.json` `version`, `global.json` `msbuild-sdks`, `Directory.Packages.props` | read the file. A quick-start snippet's pin is checked by the repository's version-sync check, if there is one | +| A skill exists, and what it answers | `skills//SKILL.md` frontmatter | `name` and `description`; a skill listed in `AGENTS.md` must exist, and every authored skill must be listed | +| "Tested by X" | the test | `git grep -n 'X'` under the test folder, and read the test: does it assert what the document says it does? | +| Behaviour ("the SDK does Y when Z") | the code path, and ideally the test that pins it | find both. If no test pins it, say so in your report rather than strengthening the claim | + +## Reading a document for claims + +Go through a page section by section and list every checkable statement before changing anything. +For each, record **kept** (found, matches), **fixed** (found, differs — the code wins), **removed** +(not found anywhere) or **question** (found but ambiguous, or the code looks wrong). Report the +removed and question rows to the user: a removed claim may describe something that should exist. + +## Things that look true and are not + +- **A comment in a `.props` file** describing a default the next line no longer sets. +- **The same table in two places,** both stale. Fix both from the code, then delete one and link to + the other where the tests allow it. +- **A recipe name in a doc** from before the `Justfile` was split into modules: check + `just --list`, not memory. +- **An example `global.json` or package version** — usually a pin the version-sync check owns; leave + the number to it. +- **"Removed" features** still described in a page nobody re-read when they went. Search for the + feature's property names as well as its title. diff --git a/skills/purview-buildsdk-docs-sync/references/wiki-structure.md b/skills/purview-buildsdk-docs-sync/references/wiki-structure.md new file mode 100644 index 0000000..a4b7c53 --- /dev/null +++ b/skills/purview-buildsdk-docs-sync/references/wiki-structure.md @@ -0,0 +1,109 @@ +# The wiki structure + +`docs/wiki/` is the source for the project's pages on the documentation site. The site's sync reads +the folder, publishes each page at a URL derived from its path, and rewrites relative `.md` links into +site routes. That makes the folder tree part of the published interface, not just filing. + +## Layout + +```text +docs/wiki/ + _Sidebar.md page order and sections (not published) + Home.md the browsing index (not published) + Getting-Started.md the landing page (docs.rootPage) + .md any page the site catalogue deep-links (docsPage) + / + .md + / + ... +``` + +- **Root pages need a site contract.** Only the landing page and the pages the site's project + catalogue deep-links through `docsPage` stay at the root. A deep link is a single path segment, so + such a page cannot live in a folder unless the catalogue entry moves with it. Find the contract in + the site's catalogue entry for the project (`docs.rootPage`, every `useCases[].docsPage`). +- **One level of folders.** A page is at the root or in exactly one topic folder. A topic folder is + named for the area (`configuration/`, `packaging/`), lower case, and holds at least two pages unless + the area is clearly going to grow. Do not name a folder `build/` — the standard .NET `.gitignore` + ignores `[Bb]uild/`. +- **URL-safe names.** Folder and page names are letters, digits and single dashes. The path becomes + the slug, lower-cased: `packaging/SourceLink.md` → `/docs//packaging/sourcelink/`. +- **One title.** Each page opens with exactly one `# ` heading — the title the site shows — and a + one-paragraph description of what the page answers. Section headings are `##` and below. +- **Relative links, from the file they are in.** From `configuration/Property-Schema.md`, the reference + page is `../Configuration-Reference.md` and a sibling is `Version-Detection.md`. A link that climbs + out of `docs/wiki/` (`../../README.md`) is a repository path; the site rewrites it to a GitHub URL. + +## `_Sidebar.md` + +```markdown + + +- [Home](Home.md) +- **Get started** + - [Getting Started](Getting-Started.md) + - [Engineering Principles](concepts/Engineering-Principles.md) +- **Configuration** + - [Configuration Reference](Configuration-Reference.md) + - [Property Schema](configuration/Property-Schema.md) +``` + +Every page appears exactly once, with its path from `docs/wiki/`. A page missing here is published +with no place in the sidebar. A page listed twice has two competing positions. + +## `Home.md` + +```markdown +# Wiki + + + +## How this wiki is organised + +| Section | Where it lives | +| -- | -- | +| Get started | the wiki root and `concepts/` | +| Configuration | `Configuration-Reference.md` and `configuration/` | + +## Get started + +| Page | What it answers | +| -- | -- | +| [Getting Started](Getting-Started.md) | Installing the SDK and building a first project. | +``` + +The `## ` headings are the sidebar's section labels, word for word, in the same order. Every page is +linked from at least one section table. Feature highlights and requirements may follow the sections. + +## Moving or renaming a page + +The published URL changes, and inbound links — from other repositories, from search results, +from bookmarks — break. So: + +1. Move it with `git mv`, so history follows the file. +2. Fix every inbound link: other wiki pages (relative to *their* folder), `_Sidebar.md`, `Home.md`, the + READMEs, `AGENTS.md`, the skills, and any test that names the path. +3. Record `old-slug: new/slug` pairs (lower-cased, no `.md`) and ask the user to add them to the site + catalogue's `docs.redirects`, which keeps the old URL working. Editing the site's repository is + outside this repository — do not do it unasked. + +## The test + +A repository with a wiki should hold it to these rules in its test suite, reading files only, with +no network: + +| Assertion | Catches | +| -- | -- | +| the wiki folder resolves to this repository (`_Sidebar.md`, `Home.md` and the landing page exist) | every other assertion passing vacuously | +| every page is at the root or one folder deep, and the folders are exactly the expected set | accidental nesting, a stray new topic | +| root pages are exactly the site-contract pages | a page left at the root, or a deep-linked page moved | +| every folder and page name is URL-safe | a slug the sync would rewrite | +| `_Sidebar.md` lists every page exactly once | an unpublished or doubly-ordered page | +| `Home.md` links every page | a page no reader can browse to | +| every sidebar section has a matching `## ` in `Home.md` | the two indexes disagreeing | +| every relative `.md` link in the wiki resolves | a page moved without its inbound links | +| every page opens with exactly one `# ` title (code fences and inline code ignored) | untitled or doubly-titled pages | + +Strip fenced code blocks and inline code before reading headings or links. A quoted `# comment` in a +shell example is not a title. diff --git a/skills/purview-buildsdk-justfile/SKILL.md b/skills/purview-buildsdk-justfile/SKILL.md new file mode 100644 index 0000000..0ac76c2 --- /dev/null +++ b/skills/purview-buildsdk-justfile/SKILL.md @@ -0,0 +1,185 @@ +--- +name: purview-buildsdk-justfile +description: Set up or update the Justfile of a repository that uses Purview.BuildSdk - a root Justfile that imports one barrel, .build/just/index.just, over shared modules for .NET (restore, build, test, pack, ensure-tools), version display and `just bump major|minor|patch`, local Purview.Build pipeline runs (pipeline-pr, pipeline-local-release), the agent-skill targets (skills-add, skills-install, skills-update, skills-check, skills-fix), lint-check/lint-fix, vs, scrub, an optional pm-docs-site Docs group, and exported telemetry opt-outs and .NET CLI speed-ups. Use when adding a Justfile, splitting or de-duplicating one, adding a just recipe, bumping the version from just, replacing hand-written skill scripts (update-agent-skills.ts, package.json skills:* scripts) with the SDK targets, or when a recipe runs the wrong project, runner or agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# The Justfile + +A repository's recipes are mostly the same everywhere: restore, build, test, pack, lint, the +pipeline, the skill targets. Written into each `Justfile` by hand they drift, and every repository +fixes the same quoting bug separately. So the shared recipes are **modules**, copied verbatim from +this skill's [`assets/just/`](assets/just/index.just) into the repository, and the root `Justfile` +holds only what is specific to that repository. + +```text +Justfile settings, one import, the repository's variables and own recipes +.build/just/ + index.just the barrel: imports every module, optional ones with import? + env.just exported telemetry opt-outs and .NET CLI speed-ups + version.just version, bump + bump-version.ts what `just bump` runs (Bun) + dotnet.just ensure-tools, restore, build, clean, test, pack + utilities.just lint-check, lint-fix, vs, scrub + purview-build.just pipeline-pr, pipeline-build, pipeline-tests + skills.just skills-add, -install, -update, -check, -fix + purview-build-nuget.just optional: pipeline-pack-validate, pipeline-local-release, pipeline-release + docs.just optional: docs, docs-install, docs-check +``` + +`.build/` is not matched by the stock `.gitignore` templates — `[Bb]uild/` does not match a leading +dot — but confirm it with `git check-ignore -v .build/just/index.just` (no output, exit 1, means +tracked). If a rule matches, add `!/.build/` after it. + +## The root Justfile + +```just +# Recipes for . Run `just` for the list. +# +# The shared recipes come from .build/just/, copied verbatim from the purview-buildsdk-justfile +# skill. Change them there, not here; this file holds what belongs to this repository. + +set quiet + +# Let a definition here override a module's default variable or no-op hook. +set allow-duplicate-variables +set allow-duplicate-recipes + +import ".build/just/index.just" + +solution_file := "src/Acme.slnx" + +[private] +default: + just --list +``` + +**Import the barrel and nothing else.** Adding or removing a module never touches the root file. + +Both `allow-duplicate-*` settings are required. Every module gives its variables a default, and a +definition in the root `Justfile` replaces it — regardless of where it sits relative to the import. +Without the settings, a duplicate is an error. + +## Variables the modules read + +Override only what differs from the default. `solution_file` is the one nearly every repository sets. + +| Variable | Module | Default | Notes | +| -- | -- | -- | -- | +| `solution_file` | `dotnet.just` | `.` | the `.slnx`/`.sln` every recipe builds; `.` lets dotnet find the only one in the root | +| `build_configuration` | `dotnet.just` | `Debug` | | +| `artifacts_folder` | `dotnet.just` | `./artifacts` | `pack` output; `scrub` deletes it | +| `test_filter` | `dotnet.just` | `/*/*/*/*/` | MTP tree-node filter, `just test` with no argument | +| `version_file` | `version.just` | `package.json` | read by just itself, no Bun needed | +| `current_version` | `version.just` | from `version_file` | shown by `build`, `pack`, `version` | +| `csharpier` | `utilities.just` | `dotnet csharpier` | | +| `pipeline_tool` | `purview-build.just` | `dotnet purview-build` | | +| `skills_project` | `skills.just` | `solution_file` | always the solution — see [the skill recipes](references/skills-recipes.md) | +| `docs_site` | `docs.just` | `src/docs-site` | pm-docs-site's `SITE_ROOT` | + +To find `solution_file`: an existing `Justfile` variable or the path its `build`/`test` recipes use; +otherwise the only `*.slnx`/`*.sln` outside `bin/`, `obj/`, `node_modules/` and `artifacts/`; with +several, the one CI builds. Ask only if that still leaves more than one. + +## Hooks + +`_after-bump` runs after a successful `just bump` and does nothing by default. A repository that +pins its version anywhere else — docs, templates — overrides it in the root `Justfile`: + +```just +_after-bump: version-sync +``` + +## Optional modules + +The barrel imports these with `import?`, so **the file's presence is the switch**: copy it into +`.build/just/` to turn it on, delete it to turn it off. Never edit `index.just` for this. + +- **`purview-build-nuget.just`** — copy it when the repository packs NuGet packages: any project is + packable, or `.config/purview-build.json` has a `PackValidation` section. Pack validation reads + `PackValidation.RequiredContent`, which lists every file each package must contain. A packed file + that it does not list fails the run (`contains undeclared content ... not defined in + RequiredContent`), so a new file under a package's `Sdk/`, `build/` or `buildTransitive/` needs an + entry there as well. +- **`docs.just`** — copy it when the repository has a pm-docs-site project site: a `pm-docs-site.json` + somewhere, usually `src/docs-site/`. Set `docs_site` to that folder when it is not `src/docs-site`. + This module **is** the `Docs` group pm-docs-site's spec asks for, so do not add a second one to the + root `Justfile`. + +## The environment + +`env.just` exports, to every process a recipe starts: + +- **Telemetry opt-outs**: the .NET CLI, MSBuild, Aspire, the Azure SDKs and Azure MCP, PowerShell, + Git Credential Manager, Next.js, Astro, Yarn, Claude Code, plus `DO_NOT_TRACK`. +- **.NET CLI speed and noise**: `DOTNET_CLI_ENABLEAOT` (the .NET 11 NativeAOT CLI host) and + `DOTNET_NOLOGO`. + +The MSBuild server (`DOTNET_CLI_USE_MSBUILD_SERVER`) is deliberately **not** forced on. One shared, +long-lived MSBuild process keeps the NuGet SDK resolver's settings from the first build. A later build +under a different `nuget.config` then resolves with the wrong sources and fallback folders, and fails +with `MSB4242`. Purview.BuildSdk's own package-consumption tests do exactly that, and fail. Leave it to +the SDK default unless every build on the machine shares one NuGet configuration. + +Each is `env(NAME, default)`, so a value already in the environment — a developer who opted back in, a +CI job — wins. Multithreaded MSBuild (`-mt`) is not set here: Purview.BuildSdk bootstraps it into +`Directory.Build.rsp`, which a plain `dotnet build` and an IDE also read. + +## Version + +`just version` prints the version from `package.json`. `just bump major|minor|patch` increments that +part and resets the parts after it — `2.0.1` → `3.0.0` / `2.1.0` / `2.0.2` — dropping any +pre-release suffix. It rewrites only the `"version"` value, so the file keeps its formatting. +`just bump patch --dry-run` prints the result and writes nothing. Any other part is rejected before +anything runs. It needs Bun. + +## Adopting it, or migrating an existing Justfile + +1. **Read the whole existing `Justfile`**, any `*.just` it imports, CI workflows, and hook config + (`.config/lefthook.yml`, `lefthook.yml`, `.husky/`). Write down every recipe name something outside + the `Justfile` calls. +2. **Copy the modules** from [`assets/just/`](assets/just/index.just) into `.build/just/`, byte for + byte, plus the optional ones that apply. Do not edit them in place. +3. **Write the root `Justfile`** as above, with the variables the repository needs. +4. **Delete every recipe a module now provides.** A copy in the root silently *replaces* the module's + recipe (that is what `allow-duplicate-recipes` does), so a leftover duplicate is not harmless: it + keeps the old behaviour and hides the fix. Keep a root recipe only when it does something + different on purpose, and comment why. +5. **Keep what is the repository's own**: recipes that run its own scripts, apps or migrations. Put + them in the root `Justfile` under their own `[group(...)]`. +6. **Map renamed recipes** and update every caller found in step 1: + + | Old | Now | + | -- | -- | + | `ensure-pipeline-tool`, `restore-tools` | `ensure-tools` | + | `current_version` (recipe) | `version` | + | `import "Justfile.skills.just"` | `skills.just`, through the barrel — delete the old file | + | a hand-written `Docs` group | `docs.just` | + +7. **Do not add `set shell` or `set windows-shell`.** The modules are written for just's default `sh` + — Git Bash on Windows — so they run unchanged on every OS. + +## Changing a module + +Change it here, in the skill's `assets/just/`, then copy the file into each repository. A repository +that edits its copy drifts from every other, and the next copy overwrites the edit. If one repository +needs different behaviour, override the recipe or variable in its root `Justfile`. + +Recipe comments: just shows a recipe's **last** comment line in `just --list`. A recipe with a +multi-line comment carries `[doc('...')]` with its one-line summary. + +## Verify + +```bash +git check-ignore -v .build/just/index.just # no output: tracked +just --list # every group, one-line descriptions +just --dry-run build # the solution path is the right one +just bump patch --dry-run # prints the next version, writes nothing +just skills-update -p:PurviewSkillsDryRun=true +``` + +For the skill recipes in detail, replacing hand-written skill scripts, and the runner and agent +settings, see [references/skills-recipes.md](references/skills-recipes.md). diff --git a/skills/purview-buildsdk-justfile/assets/just/bump-version.ts b/skills/purview-buildsdk-justfile/assets/just/bump-version.ts new file mode 100644 index 0000000..8cb38ff --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/bump-version.ts @@ -0,0 +1,62 @@ +// Bumps one part of the semantic version in package.json, for `just bump `. +// +// The parts after the one bumped are reset to 0, and a pre-release or build suffix is dropped: +// +// 2.0.1 major -> 3.0.0 minor -> 2.1.0 patch -> 2.0.2 +// 2.1.0-rc.1 patch -> 2.1.1 +// +// Only the "version" value is rewritten, in place, so the file keeps its own indentation, key order +// and line endings - re-serialising the JSON would reformat it. +// +// bun bump-version.ts [package.json] [--dry-run] + +import { readFileSync, writeFileSync } from "node:fs"; + +const parts = ["major", "minor", "patch"] as const; +type Part = (typeof parts)[number]; + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const [part, file = "package.json"] = args.filter((arg) => arg !== "--dry-run"); + +if (!parts.includes(part as Part)) { + console.error(`Expected one of ${parts.join(", ")}, not '${part ?? ""}'.`); + process.exit(1); +} + +const text = readFileSync(file, "utf8"); +const versionPattern = /("version"\s*:\s*")([^"]*)(")/; +const match = versionPattern.exec(text); +if (!match) { + console.error(`No "version" field in ${file}.`); + process.exit(1); +} + +const current = match[2]; +const semver = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(current); +if (!semver) { + console.error(`'${current}' in ${file} is not a major.minor.patch version.`); + process.exit(1); +} + +let [major, minor, patch] = semver.slice(1, 4).map(Number); +switch (part as Part) { + case "major": + [major, minor, patch] = [major + 1, 0, 0]; + break; + case "minor": + [minor, patch] = [minor + 1, 0]; + break; + case "patch": + patch += 1; + break; +} + +const next = `${major}.${minor}.${patch}`; +if (dryRun) { + console.log(` ${file}: ${current} -> ${next} (dry run, nothing written)`); + process.exit(0); +} + +writeFileSync(file, text.replace(versionPattern, `$1${next}$3`)); +console.log(` ${file}: ${current} -> ${next}`); diff --git a/skills/purview-buildsdk-justfile/assets/just/docs.just b/skills/purview-buildsdk-justfile/assets/just/docs.just new file mode 100644 index 0000000..646d444 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/docs.just @@ -0,0 +1,26 @@ +# Optional: the project site a pm-docs-site skill (purview-dev/pm-skills) scaffolds. Copy this file +# into the module folder only when the repository has one; the barrel imports it if present. Each +# recipe runs inside the site, so none of them needs `cd`. Requires Bun. + +# The site folder, relative to the repository root - pm-docs-site's SITE_ROOT. +docs_site := "src/docs-site" + +# Serve the project site at http://localhost:4321 +[group('Docs')] +[working-directory(docs_site)] +docs: docs-install + bun run dev + +# Install the project site's dependencies from its lockfile +[group('Docs')] +[working-directory(docs_site)] +docs-install: + bun install --frozen-lockfile + +# Test, type-check and build the project site - its build check +[group('Docs')] +[working-directory(docs_site)] +docs-check: docs-install + bun run test + bun run check + bun run build diff --git a/skills/purview-buildsdk-justfile/assets/just/dotnet.just b/skills/purview-buildsdk-justfile/assets/just/dotnet.just new file mode 100644 index 0000000..9507263 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/dotnet.just @@ -0,0 +1,53 @@ +# .NET recipes: tools, restore, build, clean, test and pack, all against one solution. +# +# Override these in the root Justfile. `solution_file` is the one nearly every repository sets; "." +# lets dotnet find the single solution or project in the repository root. + +solution_file := "." +build_configuration := "Debug" +artifacts_folder := "./artifacts" + +# Every test. A TUnit/MTP tree-node filter: /assembly/namespace/class/test. +test_filter := "/*/*/*/*/" + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[group('Build and Test')] +ensure-tools: + dotnet tool restore + +# Restore NuGet packages for the solution +[group('Build and Test')] +restore *args: + echo "==> Restoring {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet restore "{{ solution_file }}" {{ args }} + +# Build the solution with build_configuration (default: Debug) +[group('Build and Test')] +build *args: + echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Clean the solution with build_configuration (default: Debug) +[group('Build and Test')] +clean *args: + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Run the tests matching a tree-node filter (default: every test) +# +# just test "/*/*/*/*[Category=Unit]" +# just test "/*/*/ProjectHarnessTests/*" +# +# Exit code 8 - "no test ran" - is ignored, so a filter that matches nothing in one test project does +# not fail the run for the others. +[doc('Run the tests matching a tree-node filter (default: every test)')] +[group('Build and Test')] +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} + +# Pack every packable project into artifacts_folder (default: ./artifacts) +[group('Build and Test')] +pack artifact_folder=artifacts_folder *args: + echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" + dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} diff --git a/skills/purview-buildsdk-justfile/assets/just/env.just b/skills/purview-buildsdk-justfile/assets/just/env.just new file mode 100644 index 0000000..6156f34 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/env.just @@ -0,0 +1,56 @@ +# The environment every recipe runs in: telemetry opt-outs, and the .NET CLI speed-ups. +# +# `export` hands each value to every process a recipe starts - dotnet, bun, pwsh, git. Each one is a +# default: `env(NAME, value)` keeps whatever the caller's environment already says, so a developer +# who has opted back in, or a CI job that sets its own value, is never overridden. + +# -- Telemetry and tracking -------------------------------------------------------------------------- + +# The .NET CLI, MSBuild and Aspire. +export DOTNET_CLI_TELEMETRY_OPTOUT := env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") +export MSBUILD_TELEMETRY_OPTOUT := env("MSBUILD_TELEMETRY_OPTOUT", "1") +export ASPIRE_CLI_TELEMETRY_OPTOUT := env("ASPIRE_CLI_TELEMETRY_OPTOUT", "true") +export ASPIRE_DASHBOARD_TELEMETRY_OPTOUT := env("ASPIRE_DASHBOARD_TELEMETRY_OPTOUT", "true") + +# The Azure SDKs and the Azure MCP server. +export AZURE_CORE_COLLECT_TELEMETRY := env("AZURE_CORE_COLLECT_TELEMETRY", "0") +export AZURE_MCP_COLLECT_TELEMETRY := env("AZURE_MCP_COLLECT_TELEMETRY", "0") + +# PowerShell, for scripts a recipe runs through pwsh, and Git Credential Manager. +export POWERSHELL_TELEMETRY_OPTOUT := env("POWERSHELL_TELEMETRY_OPTOUT", "1") +export GCM_TELEMETRY_OPTOUT := env("GCM_TELEMETRY_OPTOUT", "1") + +# JavaScript tooling: Next.js, Astro, Yarn, and the funding banners printed by postinstall scripts. +export NEXT_TELEMETRY_DISABLED := env("NEXT_TELEMETRY_DISABLED", "1") +export ASTRO_TELEMETRY_DISABLED := env("ASTRO_TELEMETRY_DISABLED", "1") +export YARN_ENABLE_TELEMETRY := env("YARN_ENABLE_TELEMETRY", "0") +export DISABLE_OPENCOLLECTIVE := env("DISABLE_OPENCOLLECTIVE", "true") + +# Claude Code, when a recipe launches it. +export CLAUDE_CLI_TELEMETRY_OPTOUT := env("CLAUDE_CLI_TELEMETRY_OPTOUT", "1") +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC := env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") +export DISABLE_ERROR_REPORTING := env("DISABLE_ERROR_REPORTING", "1") +export DISABLE_TELEMETRY := env("DISABLE_TELEMETRY", "1") + +# The cross-tool conventions, honoured by Bun and many other CLIs. +export DO_NOT_TRACK := env("DO_NOT_TRACK", "1") +export TELEMETRY_DISABLED := env("TELEMETRY_DISABLED", "1") + +# -- .NET CLI speed and noise ------------------------------------------------------------------------ + +# No welcome banner or first-run notice. +export DOTNET_NOLOGO := env("DOTNET_NOLOGO", "1") + +# The NativeAOT CLI host (.NET 11): near-instant `dotnet --version`, `--help` and tool launches, with +# everything else falling back to the managed CLI. On by default; opt out with DOTNET_CLI_ENABLEAOT=false. +export DOTNET_CLI_ENABLEAOT := env("DOTNET_CLI_ENABLEAOT", "true") + +# The MSBuild server (DOTNET_CLI_USE_MSBUILD_SERVER) is deliberately left to the SDK's own default. +# Forcing it on makes every `dotnet build` share one long-lived MSBuild process, and its NuGet SDK +# resolver keeps the settings it first read: a build under a different nuget.config - a test +# harness's throwaway consumer, a second repository - then resolves SDKs with the wrong sources and +# fallback folders, and fails with MSB4242. Set it yourself where every build shares one NuGet +# configuration; `just scrub` shuts the server down. + +# Multithreaded MSBuild (-mt) is deliberately not set here. Purview.BuildSdk bootstraps it into the +# repository's Directory.Build.rsp, so a plain `dotnet build` and an IDE build get it as well. diff --git a/skills/purview-buildsdk-justfile/assets/just/index.just b/skills/purview-buildsdk-justfile/assets/just/index.just new file mode 100644 index 0000000..1d73bbe --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/index.just @@ -0,0 +1,41 @@ +# The barrel for the shared just modules. The root Justfile imports this file, and nothing else: +# +# set allow-duplicate-variables +# set allow-duplicate-recipes +# +# import ".build/just/index.just" +# +# Every module gives its variables a default. The root Justfile overrides one by defining the same +# name - `solution_file := "src/Acme.slnx"` - and overrides a no-op hook such as `_after-bump` the +# same way. Both settings are what let a root definition win; without them a duplicate is an error. +# +# The required modules are imported unconditionally. The optional ones use `import?`, so a module is +# switched on by copying its file into this folder and off by deleting it: this file never changes. +# +# These files are copied verbatim from the purview-buildsdk-justfile skill. Change a recipe there, +# not here, and put anything specific to one repository in its root Justfile. + +# Telemetry opt-outs and the .NET CLI speed-ups, exported to every recipe. +import "env.just" + +# version, bump +import "version.just" + +# ensure-tools, restore, build, clean, test, pack +import "dotnet.just" + +# lint-check, lint-fix, vs, scrub +import "utilities.just" + +# pipeline-pr, pipeline-build, pipeline-tests - local runs of the Purview.Build pipeline +import "purview-build.just" + +# skills-* - the Purview.BuildSdk agent-skill targets +import "skills.just" + +# Optional. pipeline-pack-validate, pipeline-local-release, pipeline-release - for a repository that +# packs NuGet packages. +import? "purview-build-nuget.just" + +# Optional. docs, docs-install, docs-check - for a pm-docs-site project site (purview-dev/pm-skills). +import? "docs.just" diff --git a/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just b/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just new file mode 100644 index 0000000..2154876 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just @@ -0,0 +1,30 @@ +# Optional: Purview.Build pipeline runs that pack, validate and publish NuGet packages. Copy this file +# into the module folder only when the repository packs packages; the barrel imports it if present. +# +# Pack validation reads PackValidation in .config/purview-build.json. Its RequiredContent lists every +# file each package must contain, and a packed file it does not list fails the run - so a new file +# under a package's Sdk/, build/ or buildTransitive/ needs a RequiredContent entry too. + +# Run the pipeline through pack and pack validation (restore, build, lint, tests, pack, validate), publishing nothing +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish) +# +# `just` runs recipes through sh, which strips backslashes from unquoted arguments. Set the +# LOCAL_NUGET_FEED_PATH environment variable, or use forward slashes: +# +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[doc('Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish)')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the release pipeline, publishing to NuGet (restore, build, lint, tests, pack, validate, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: ensure-tools + echo "==> Running the release pipeline" + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} diff --git a/skills/purview-buildsdk-justfile/assets/just/purview-build.just b/skills/purview-buildsdk-justfile/assets/just/purview-build.just new file mode 100644 index 0000000..244e44c --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/purview-build.just @@ -0,0 +1,24 @@ +# Local runs of the Purview.Build pipeline - the same `dotnet purview-build` tool, and the same +# .config/purview-build.json, that CI runs. Every Build stage - tests, lint, pack and pack validation - +# runs unless that file or an argument turns it off. Anything after the recipe name is passed to the +# tool, so a setting can be overridden for one run: `just pipeline-pr --Build:TestFilter="/*/*/*/*[Category=Unit]"`. + +pipeline_tool := "dotnet purview-build" + +# Run the PR pipeline (restore, build, lint, tests, pack, validate), as configured +[group('Pipeline')] +pipeline-pr *args: ensure-tools + echo "==> Running the PR pipeline" + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint, pack, validate), without tests +[group('Pipeline')] +pipeline-build *args: ensure-tools + echo "==> Running the build pipeline" + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the pipeline with tests forced on and release off (restore, build, lint, tests, pack, validate) +[group('Pipeline')] +pipeline-tests *args: ensure-tools + echo "==> Running the tests pipeline" + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} diff --git a/skills/purview-buildsdk-justfile/assets/just/skills.just b/skills/purview-buildsdk-justfile/assets/just/skills.just new file mode 100644 index 0000000..0d33902 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/skills.just @@ -0,0 +1,52 @@ +# The Purview.BuildSdk agent-skill targets. Each recipe names one target; the behaviour lives there. +# +# The targets are a thin wrapper over the standard skills CLI (`bun x skills` by default), run from the +# repository root. Every skill target runs once for the whole solution, however many projects it holds, +# so the recipes name the solution, not a hand-picked project. +# +# Every recipe here is run by a person, on purpose. Never make one a dependency of build, test or +# default, or call skills-add, -install, -update or -fix from a git hook: they change the repository, +# and the SDK refuses to run them inside a build (PRSKL0009). skills-check is read-only. +# +# The runner and agents are MSBuild properties (PurviewSkillsRunner, PurviewSkillsAgents), set once in +# Directory.Build.props. Pass a one-off through *args; `dotnet msbuild -p:` splits on `,` and `;`, so +# escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +skills_project := solution_file + +# Install skills from another repository through the skills CLI (append '#' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#' to pin it)")] +[group('Agent Skills')] +skills-add source *args: + echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }}" + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} -v:n {{ args }} + +# Restore every skill skills-lock.json records - how a fresh clone gets the repository's skills back +[group('Agent Skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Update the agent skills by re-adding every unique source in skills-lock.json +# +# Each source is re-added at its latest with every skill it has, which refreshes the installed skills +# and adds any a source has gained; `skills update` is never run. FixPurviewSkills then removes links +# from agent directories PurviewSkillsAgents does not select, and does nothing when it is unset. +# The -t: value is quoted because sh would otherwise end the command at the `;`. +# +# just skills-update -p:PurviewSkillsDryRun=true +[doc('Update the agent skills by re-adding every unique source in skills-lock.json')] +[group('Agent Skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent Skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent Skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/skills/purview-buildsdk-justfile/assets/just/utilities.just b/skills/purview-buildsdk-justfile/assets/just/utilities.just new file mode 100644 index 0000000..d813497 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/utilities.just @@ -0,0 +1,44 @@ +# Formatting, opening the solution, and scrubbing the build output. + +csharpier := "dotnet csharpier" + +# Check formatting with CSharpier +[group('Utilities')] +lint-check: + {{ csharpier }} check . + +# Format the code with CSharpier +[group('Utilities')] +lint-fix: + {{ csharpier }} format . + +# Open the solution in Visual Studio, or whichever application is registered for it +[group('Utilities')] +[windows] +vs: + start "" "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[macos] +vs: + open "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[linux] +vs: + xdg-open "{{ solution_file }}" + +# Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch +# +# The build servers go first: the MSBuild server and the compiler server hold files open under obj/, +# which on Windows stops them being deleted. node_modules/ and .git/ are never searched. +[doc('Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch')] +[group('Utilities')] +scrub: + echo "==> Scrubbing build output and stopping the build servers" + dotnet build-server shutdown + find . \( -name node_modules -o -name .git \) -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + rm -rf "{{ artifacts_folder }}" + just restore --force-evaluate diff --git a/skills/purview-buildsdk-justfile/assets/just/version.just b/skills/purview-buildsdk-justfile/assets/just/version.just new file mode 100644 index 0000000..09dbb37 --- /dev/null +++ b/skills/purview-buildsdk-justfile/assets/just/version.just @@ -0,0 +1,28 @@ +# The package version, from package.json: the single source of truth Purview.BuildSdk versions every +# project and package from. + +version_file := "package.json" + +# Read by just itself rather than by spawning bun, so `just --list` needs no Node tooling. +current_version := if path_exists(version_file) == "true" { replace_regex(read(version_file), '(?s).*?"version"\s*:\s*"([^"]*)".*', '$1') } else { "0.0.0" } + +# Display the current version, from package.json +[group('Version')] +version: + echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (from {{ version_file }}, stamped into every build by Purview.BuildSdk)" + +# Bump one part of the version in package.json - major, minor or patch - resetting the parts after it +# +# just bump minor # 2.0.1 -> 2.1.0 +# just bump patch --dry-run # print the new version, write nothing +# +# A pre-release suffix is dropped. Requires Bun. Afterwards it runs `_after-bump`, which does nothing +# unless the root Justfile overrides it to refresh whatever else pins the version. +[doc('Bump one part of the version in package.json - major, minor or patch - resetting the parts after it')] +[arg("part", pattern="major|minor|patch")] +[group('Version')] +bump part *args: && _after-bump + bun "{{ source_directory() / 'bump-version.ts' }}" "{{ part }}" "{{ version_file }}" {{ args }} + +# Runs after a successful `bump`. Override it in the root Justfile, e.g. `_after-bump: version-sync`. +_after-bump: diff --git a/skills/purview-buildsdk-justfile/references/skills-recipes.md b/skills/purview-buildsdk-justfile/references/skills-recipes.md new file mode 100644 index 0000000..5c850f5 --- /dev/null +++ b/skills/purview-buildsdk-justfile/references/skills-recipes.md @@ -0,0 +1,100 @@ +# The agent-skill recipes + +`skills.just` gives each SDK skill target a recipe. The targets are a thin wrapper over the standard +skills CLI (`bun x skills` by default), which finds a repository's skills in its root `skills/` +folder and records what it installed in `skills-lock.json`. Their behaviour is covered by +`purview-buildsdk-skill-installation`; this page is about the recipes that run them. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the `Justfile` consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-add`, `skills-install`, `skills-update` or `skills-fix` from a git hook + (`lefthook.yml`, `.husky/`) or from a CI job that commits — `PRSKL0009` cannot see those, because they + run the target on its own; +- `skills-check` is read-only and safe anywhere. + +## The recipes + +| Recipe | Target | Runs | Changes the repository | +| -- | -- | -- | -- | +| `skills-add ` | `InstallPurviewSkills` with `PurviewSkillsSource` | `skills add --skill "*"` | yes | +| `skills-install` | `InstallPurviewSkills` | `skills experimental_install` — restore `skills-lock.json` | yes | +| `skills-update` | `UpdatePurviewSkills;FixPurviewSkills` | `skills add --skill "*"` once per lockfile source | yes | +| `skills-check` | `CheckPurviewSkills` | nothing | no | +| `skills-fix` | `FixPurviewSkills` | nothing; removes links itself | yes | + +A repository that *authors* skills usually adds `skills-validate` to its root `Justfile`. It runs +`ValidatePurviewSkills` against the one project that packs, because that target runs once per +project it is invoked on: + +```just +[group('Agent Skills')] +skills-validate *args: + dotnet msbuild "src/src/Acme/Acme.csproj" -t:ValidatePurviewSkills -v:n {{ args }} +``` + +## Why the recipes name the solution + +`skills_project` defaults to `solution_file`. Every skill target runs once for the whole solution, +however many projects it holds: each project hands the work to the same elected leader, and MSBuild +runs it once. A single project works too. An old `Justfile.skills.just` pointed at a hand-picked +"small" project gets the same treatment: delete it, and let `skills.just` name the solution. + +## Details that break a recipe if missed + +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` ends the + command at the `;` and runs `FixPurviewSkills` as a separate, failing command. The same holds under + PowerShell. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. +- `skills-install` with no lockfile fails with `PRSKL0007`: add a source first with `skills-add`. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, so +a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so escape +the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | What `skills-add` sets: the source to install from | + +## Replacing hand-written skill scripts + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: each +source is re-added with `--skill "*"`. Mention that when migrating. + +Before deleting the scripts, run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran. Then delete the scripts and their `package.json` +entries — but keep `package.json` itself, which Purview.BuildSdk reads the version from. + +An existing `skills-recommend`, `skills-verify` or `skills-list` recipe has no target behind it any +more: delete it, and its callers. diff --git a/skills/purview-buildsdk-msbuild-properties/SKILL.md b/skills/purview-buildsdk-msbuild-properties/SKILL.md new file mode 100644 index 0000000..0011124 --- /dev/null +++ b/skills/purview-buildsdk-msbuild-properties/SKILL.md @@ -0,0 +1,233 @@ +--- +name: purview-buildsdk-msbuild-properties +description: Add, rename, deprecate or remove an MSBuild property in a package built with Purview.BuildSdk, and describe it in an MSBuild Editor .buildschema.json sidecar so it gets csproj IntelliSense. Use when a package ships .props/.targets under Sdk/, build/ or buildTransitive/ and needs a new setting, a compiler-visible (build_property.*) value for its analyzers, a documented default, or a check that its schema and props agree (ValidatePurviewPropertySchema, PRPSC0001-PRPSC0004). Also covers where a sidecar must sit to be loaded, when a property must be set before the Sdk.props import, and declaring a new sidecar in .config/purview-build.json PackValidation.RequiredContent - including a Purview.Build "contains undeclared content ... not defined in RequiredContent" failure. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# MSBuild properties and their schema + +A property a package ships is described in several places: its definition and default, the +`CompilerVisibleProperty` list when an analyzer reads it, the package's docs, and an MSBuild Editor +sidecar schema that gives it csproj IntelliSense. They drift unless one of them is canonical and the +others are checked against it. + +**The sidecar is canonical.** Write the description, type and default there first, then make the code +and the docs agree with it. `ValidatePurviewPropertySchema` checks the part that can be checked +mechanically. + +## Where a sidecar goes + +MSBuild Editor reads `.buildschema.json` — the **full** file name plus the +suffix, in the **same folder** as the file it describes. Verified in the editor's source +(`MSBuildSchemaProvider.GetSchema`: `path + ".buildschema.json"`). + +| The package ships | The consumer's build imports | The sidecar is | +| -- | -- | -- | +| an MSBuild SDK (`Sdk/Sdk.props`, `Sdk/Sdk.targets`) | `Sdk/Sdk.props` | `Sdk/Sdk.props.buildschema.json` | +| `build/Acme.Kit.props` | `build/Acme.Kit.props` | `build/Acme.Kit.props.buildschema.json` | +| `buildTransitive/Acme.Kit.props` | `buildTransitive/Acme.Kit.props` | `buildTransitive/Acme.Kit.props.buildschema.json` | + +Three consequences: + +- `Sdk.buildschema.json` or `Acme.Kit.buildschema.json` is **never loaded**: the extension of the + described file is part of the name. `PRPSC0004` reports a sidecar with no file beside it. +- One sidecar is enough per package. The editor loads every sidecar for every file it imports and + applies them project-wide, so describe all the package's properties beside the file a consumer + always imports — usually the `.props` — even those its `.targets` defines. +- A package that ships the same file under both `build/` and `buildTransitive/` needs the sidecar in + both, because a direct and a transitive reference import different copies. + +With Purview.BuildSdk, author the files under the project's `Sdk/` folder: `PurviewAutoSdkPack` maps +`Sdk/build/**` to `build/**` and `Sdk/buildTransitive/**` to `buildTransitive/**`, and a sidecar +travels with its neighbour because it sits in the same folder. An MSBuild SDK project disables +`PurviewAutoSdkPack` and packs `Sdk/**` to `Sdk/` itself; a sidecar there is picked up by that same +item. + +### Declare the sidecar to pack validation + +A sidecar is a new file in the package, and a repository built with Purview.Build validates every +package's contents against `.config/purview-build.json`. Whenever you add a sidecar — or any other +`.props`/`.targets` the package ships — open that file and check `PackValidation.RequiredContent`. +It is keyed by lower-case package id, and lists the package-relative path of every file the package +must contain: + +```json +"PackValidation": { + "RequiredContent": { + "acme.kit": [ + "build/Acme.Kit.props", + "build/Acme.Kit.props.buildschema.json", + "buildTransitive/Acme.Kit.props", + "buildTransitive/Acme.Kit.props.buildschema.json" + ] + } +} +``` + +Add each sidecar beside the file it describes, in the list's existing order. Both directions fail +the pipeline's `ValidatePackModule`, and neither shows up until `just pipeline-pack-validate` or a +release: + +- **A packed file the list does not name** — `contains undeclared content 'Sdk/Sdk.props.buildschema.json' + that is not defined in RequiredContent`. The package is right; the list is stale. +- **A listed file the package lacks** — the sidecar was renamed, or did not pack. Fix the pack, not + the list. + +With no `.config/purview-build.json`, or no entry for the package, there is nothing to update. To +check, pack and validate without publishing: + +```bash +just pipeline-pack-validate # or: dotnet purview-build --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None +``` + +## Add a property + +1. **Define it, with its default, in the `.props`.** Guard it so a value set earlier wins: + + ```xml + false + ``` + + Give a boolean an explicit `true`/`false` default. Leave a property unset only when "unset" is a + real third state — "detected", "inherited from another property" — and say so in the schema. + +2. **Decide whether it must be set before the import.** A `.props` file is evaluated before the + project body. If anything in the `.props` (or a file it imports) *reads* the property — in a value + or a `Condition`, other than its own `'$(X)' == ''` guard — a value set in the project file arrives + too late. Then it must be set in `Directory.Build.props` **before** the ``, and its + description says so. If only the `.targets` reads it, a project file can set it too; prefer that + shape for anything per-project. + +3. **Make it compiler-visible only if an analyzer or generator reads it.** Add it beside the package's + other exports, with a ``: + + ```xml + + + When true, the Acme analyzers report ACME0001 as an error. + + + ``` + + The analyzer reads `build_property.AcmeKitStrict` from + `AnalyzerConfigOptionsProvider.GlobalOptions`. Nothing else exposes an MSBuild property to the + compiler, and an export nothing reads is noise in every consumer's generated `.editorconfig`. + +4. **Describe it in the sidecar.** See [references/schema-format.md](references/schema-format.md) for + the entry format, types and enumerations: + + ```json + "AcmeKitStrict": { + "description": "Reports ACME0001 as an error instead of a warning.", + "type": "bool", + "defaultValue": "false" + } + ``` + + - `defaultValue` is the value a plain project evaluates to — not what it is "usually". + - No default? End the description with `No default: .` — "detected from the project", + "unset, which is treated as false". + - Must be set before the import? Say `Set it before the \`Sdk.props\` import.` (or name the + package's own `.props`). + +5. **Document it** in the package's README or docs table, with the same default. + +6. **Record it** in `CHANGELOG.md` under the heading for the version in `package.json` → + `### Added`. Not `[Unreleased]`: a merge to `main` publishes that version, so the entry would read as + unreleased until it was already live. `[Unreleased]` is only for a pre-release version. + +7. **Check it.** Run `dotnet msbuild -t:ValidatePurviewPropertySchema` (see below), then the + package's own tests. If this property brought the package's **first** sidecar, or a new + `.props`/`.targets` file, declare it to pack validation as well + ([above](#declare-the-sidecar-to-pack-validation)). + +## Rename a property + +Never rename a released property in place: every consumer that sets the old name silently loses it. + +1. Add the new name as in *Add a property*, defaulting from the old one so an existing setting still + works: + + ```xml + $(AcmeKitStrict) + false + ``` + +2. Read only the new name everywhere else, including the `CompilerVisibleProperty` export. Export the + old one too until it is removed if analyzers in the wild read it. +3. Keep the old entry in the sidecar, with a `deprecationMessage` naming the replacement, so the editor + flags the old name where it is set: + + ```json + "AcmeKitStrict": { + "description": "Replaced by AcmeKitStrictMode.", + "type": "bool", + "deprecationMessage": "Use AcmeKitStrictMode instead." + } + ``` + +4. Changelog: `### Added` for the new name, `### Deprecated` for the old. + +## Deprecate a property + +Keep its behaviour and its default, add a `deprecationMessage` to its sidecar entry saying what to do +instead, mark the docs row deprecated, and add a `### Deprecated` changelog entry. A deprecated +property is still a property: it keeps its schema entry until it is removed. + +## Remove a property + +Only in a major version, and in one change: the definition, every read, the `CompilerVisibleProperty` +item, the sidecar entry and the docs row. Leaving any one behind is exactly what the checks catch — +`PRPSC0002` for a sidecar entry nothing defines. Changelog: `### Removed`, naming what to set instead. + +## Checking a package: `ValidatePurviewPropertySchema` + +Purview.BuildSdk ships an opt-in target for any package that authors `.props`/`.targets`: + +```xml + + + true + + RootNamespace;Version + +``` + +With it on, the check runs before `Pack`. Run it by name at any time: + +```bash +dotnet msbuild src/Acme.Kit/Acme.Kit.csproj -t:ValidatePurviewPropertySchema +``` + +It reads every `.props`/`.targets` under `PurviewPropertySchemaDirectory` (default: the project's +`Sdk/` folder) and every sidecar beside them, and fails with: + +| Id | Meaning | Fix | +| -- | -- | -- | +| `PRPSC0001` | a `CompilerVisibleProperty` no sidecar describes | add the entry, or list the name in `PurviewPropertySchemaIgnoredProperties` | +| `PRPSC0002` | a sidecar entry no file defines, reads or exports | remove the entry, or define the property | +| `PRPSC0003` | a sidecar that is not JSON, or has no `"properties"` object | fix the file | +| `PRPSC0004` | a sidecar with no `.props`/`.targets` beside it | rename it to `.buildschema.json` | + +It is off by default and never runs in an ordinary build, so turning it on changes nothing for the +package's consumers. It checks agreement, not prose: whether a description is *right* is the review's +job. + +## Getting the IntelliSense + +Install the **MSBuild Editor** extension in Visual Studio +(). It loads the sidecar of every file a project imports, SDKs resolved +through `global.json` `msbuild-sdks` included, and offers completion, the description, the default +and — for an enumeration — the allowed values. Without the extension a sidecar is inert. + +## Guardrails + +- **Do not change a released default** as part of documenting it. A wrong document is fixed in the + document; a default change is a behaviour change with its own changelog entry. +- **Do not export a property "in case".** Each `CompilerVisibleProperty` lands in every consumer's + generated analyzer config. +- **Do not hand-edit generated sidecars.** If a package generates its sidecar, change the generator. +- **Keep the meta-schema draft as it is** (`draft-04`): Visual Studio rejects newer drafts. diff --git a/skills/purview-buildsdk-msbuild-properties/references/schema-format.md b/skills/purview-buildsdk-msbuild-properties/references/schema-format.md new file mode 100644 index 0000000..1f4e7b3 --- /dev/null +++ b/skills/purview-buildsdk-msbuild-properties/references/schema-format.md @@ -0,0 +1,87 @@ +# The `.buildschema.json` format, for properties + +The meta-schema is +. +It declares **JSON Schema draft-04** on purpose — Visual Studio fails to validate against anything +newer — and sets `additionalProperties: false` almost everywhere, so an unknown key is an error, not +an extension point. There is no slot for custom metadata: anything a property needs to say goes in +its `description`. + +## The file + +```json +{ + "$schema": "https://github.com/mhutch/MonoDevelop.MSBuildEditor/raw/main/MonoDevelop.MSBuild/Schemas/buildschema.json", + "license": "MIT", + "properties": { + "AcmeKitStrict": { + "description": "Reports ACME0001 as an error instead of a warning.", + "type": "bool", + "defaultValue": "false" + } + }, + "types": {} +} +``` + +Top-level keys: `$schema`, `license`, `properties`, `items`, `targets`, `metadata`, `types`, +`intellisenseImports`. All optional. + +## A property entry + +A string is shorthand for `{ "description": "..." }`. The object form allows only: + +| Key | Use | +| -- | -- | +| `description` | What it does, what its values mean, whether it must be set before the import, and — when there is no `defaultValue` — `No default: .` | +| `type` | An intrinsic type name, an inline type, or `{ "$ref": "#/types/" }`. | +| `defaultValue` | The evaluated default, as a string. Omit it rather than guess. | +| `isList`, `listSeparators` | A list value. `listSeparators` is `";"`, `","` or `";,"`. | +| `isLiteral` | Only literal values, no `$(...)` expressions. | +| `deprecationMessage` | Marks the property deprecated; shown where it is set. Must not be empty. | +| `versionInfo`, `helpUrl` | Optional. | + +## Intrinsic types + +`bool`, `int`, `string`, `guid`, `url`, `version`, `version-suffixed`, `nuget-id`, `nuget-version`, +`file`, `folder`, `folder-with-slash`, `file-or-folder`, `extension`, `target-framework`, +`target-framework-moniker`, `runtime-id`, `configuration`, `platform`, `warning-code`, +`clr-namespace`, `clr-type`, `clr-type-name`, `sdk`, `sdk-version`, `target-name`, `item-name`, +`property-name`, `culture`, `lcid` and a few more — the meta-schema's `valueTypeIntrinsic` is the +complete list. Pick the narrowest: `warning-code` with `isList` for a `NoWarn`-style list, +`target-framework` with `isList` for a TFM set, `folder` for a directory. + +## Enumerations + +Define the values once under `types` and reference them: + +```json +"properties": { + "AcmeKitMode": { + "description": "How strictly the kit applies its rules.", + "type": { "$ref": "#/types/acme-kit-mode" }, + "defaultValue": "Warn" + } +}, +"types": { + "acme-kit-mode": { + "description": "A strictness level.", + "values": { + "Off": "No checks.", + "Warn": "Report as warnings.", + "Error": "Report as errors." + } + } +} +``` + +A type name is lowercase and hyphenated (`^[a-z][a-z\d-]*$`). Set `"allowUnknownValues": true` when +the listed values are suggestions rather than the whole set — a package runner, a provider package. +A value can be an object with `description`, `deprecationMessage`, `aliases` and `helpUrl` instead of +a plain description. + +## Validating + +The meta-schema can be associated in VS Code (`json.schemas` → `fileMatch: ["/*.buildschema.json"]`) +or in Visual Studio's JSON editor *Schema* box. Purview.BuildSdk validates its own sidecar against a +vendored copy of the meta-schema in its test suite, so the check runs offline. diff --git a/skills/purview-buildsdk-project-detection/SKILL.md b/skills/purview-buildsdk-project-detection/SKILL.md index 9ebc421..a893896 100644 --- a/skills/purview-buildsdk-project-detection/SKILL.md +++ b/skills/purview-buildsdk-project-detection/SKILL.md @@ -100,8 +100,10 @@ A project that declares neither `TargetFramework` nor `TargetFrameworks` gets th `All`) resolves the same way; an unknown name fails with `PurviewInvalidTargetFrameworkSet`. A Roslyn component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). -The declaration is read from the **project XML** before the body is evaluated, so a `Condition` on a -`TargetFramework`, `TargetFrameworks` or `PurviewTargetFrameworkSet` is not seen and is reported as +An explicit `TargetFramework` or `TargetFrameworks` always wins, wherever it is set: one the SDK cannot +see in the project XML (a `Condition`, an imported file, a `Directory.Build.props` value) is reconciled +after the project body, before the .NET SDK decides whether to multi-target. `PurviewTargetFrameworkSet` +is still read from the **project XML**, so a `Condition` on it is not seen and is reported as `PurviewConditionedProjectDeclaration`. See `purview-buildsdk-configuration` for how to set them. ## Namespace and identity @@ -109,8 +111,10 @@ The declaration is read from the **project XML** before the body is evaluated, s 1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the project name already starts with the prefix's tail. 2. `RootNamespace` defaults to `PurviewLogicalProjectName`. -3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments - such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +3. Known suffixes are stripped from `RootNamespace` in two passes: while `Sdk.props` evaluates, the + shared, server-shared and shared-testing names plus `Core`, `EF`, `Shared`, `ClientShared` and + `ServiceDefaults`; then, during the build, the wider `NamespaceRemoveSuffix` list (`Infrastructure`, + `Abstractions`, `Contracts`, …). A `RootNamespace` declared in the project file is left as written. 4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. 5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact @@ -152,10 +156,10 @@ read the SDK's compiler-visible properties (`build_property.*`) rather than re-d | `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | | `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | -`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, -`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, -`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, -`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Uuid`, `Url`, `Dns`, +`Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, `DbConnection` and `DbSet` +are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, +`Gui`, `Ram`, `Ssh`, `Dsl`. Members mandated by a contract — interface implementations and base-class overrides — are never renamed. Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), @@ -170,9 +174,11 @@ diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. Two Visual Studio refactorings ship with them, both described in [references/detection.md](references/detection.md). -## When changing a detection rule +## When a classification is wrong -1. Keep the `.csproj` filename equal to its directory name. -2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in - prose is this repository's recurring failure. -3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. +1. Confirm the computed value with `dotnet msbuild -getProperty:` before changing + anything. +2. Prefer fixing the name or placement so the convention applies; keep the `.csproj` filename equal + to its directory name. +3. Otherwise set the flag explicitly before the SDK import (`IsBffProject` and `IsClientSideProject` + can also be set in the project), and say why in a comment. diff --git a/skills/purview-buildsdk-project-detection/references/blazor.md b/skills/purview-buildsdk-project-detection/references/blazor.md index c65d153..8c2e6f9 100644 --- a/skills/purview-buildsdk-project-detection/references/blazor.md +++ b/skills/purview-buildsdk-project-detection/references/blazor.md @@ -12,10 +12,31 @@ prevents. Nothing here changes a project that is not a WebAssembly client, a Bla | `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | | no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | | the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | +| the managed `Directory.Build.rsp` written as `-mt:false`, naming the client | the WebAssembly SDK's `ReadWasmProps` failing under `-mt` with `MSB4060`, an error that points nowhere near the response file | `PurviewMultiThreadedBuild=true` (which then fails with `PRBLZ0002`) | +| `PRBLZ0002` when a `net11.0`+ client is built under `-mt` anyway | the same failure, reported before `PrepareForBuild` and naming where `-mt` came from | none: the build would fail anyway | Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor SDK owns them, and a second default would compete with it. +### The multithreaded build (`PRBLZ0002`) + +A `net11.0` client cannot build under MSBuild's `-mt`, because the WebAssembly SDK's `ReadWasmProps` +task comes from a custom task factory that cannot run in the out-of-process task host. The +`Directory.Build.rsp` the SDK manages is per repository, so with `PurviewMultiThreadedBuild` unset: + +- a client writes `-mt:false` and a `# -mt:false: the Blazor WebAssembly client '' ...` line; +- every other project leaves that file alone while `` exists, and writes `-mt` again once it + does not; +- a `PurviewMultiThreadedBuild` the user set always wins, and a file without the marker line is never + rewritten. + +A client built under `-mt` anyway (the command line, a response file the user owns, +`PurviewMultiThreadedBuild=true`, or the first build after upgrading from 2.0.x) stops with `PRBLZ0002`. +The message says whether `-mt` came from the command line or which `Directory.Build.rsp`, and gives the +fix: `false`, or `-mt:false`. After an upgrade, +the managed file has already been rewritten, so building again is the fix. A `net10.0` client builds +under `-mt` and is not checked. + ### The automatic packages The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` @@ -70,12 +91,14 @@ Applies to class libraries only, and only what the project has not set: - ``, so `CA1416` reports an API the browser does not support; - `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. - `TreatWarningsAsErrors` is not touched. + `TreatWarningsAsErrors` is not touched (it is `true` for every project by default), so these stay + errors in a project that turns it off. `CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` outranks an `.editorconfig` severity, so the error still fires without the repository's setting changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT -diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. +diagnostics follow `TreatWarningsAsErrors` (`true` by default) while `CA1416` falls back to the +repository's configured severity. What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports diff --git a/skills/purview-buildsdk-project-detection/references/detection.md b/skills/purview-buildsdk-project-detection/references/detection.md index 51243c9..155e345 100644 --- a/skills/purview-buildsdk-project-detection/references/detection.md +++ b/skills/purview-buildsdk-project-detection/references/detection.md @@ -29,18 +29,19 @@ These are matched whole, not as substrings. `SharedModels` is not a shared proje A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with `IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip -it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` to keep that package-driven shape. ## Packable projects Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, -`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, -`IncludeSource`, `DebugType=portable`. +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources` and +`DebugType=portable`. `IncludeSource` stays `false`. - Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. -- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and - nothing else is already being packed as one. +- A README is packed and registered as `PackageReadmeFile` unless one is configured: the project's own + `README.md` wins, else the repository-root one; a README-named file already being packed (an + `Sdk/README.md`) is used instead. - Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a solution-wide `dotnet pack` skips them silently. - `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never @@ -76,8 +77,8 @@ dual-role component that wants normal library symbol packaging. | -- | -- | | CLI | `OutputType=Exe`, `appsettings*.json` included as content | | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | -| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | -| Aspire host | `OutputType=Exe` | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless `IsWebProject=true` | +| Aspire host | `OutputType=Exe` (unless a test or shared-testing project), `CA1515` suppressed | | Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | | ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | @@ -92,7 +93,7 @@ Two ship with the code-fix assembly, both serving the `PDS0002` convention: targeting several receiver types. It produces one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//`. -- **Move extensions class to conventional location** — offered on a misplaced single-receiver - extensions class. It re-paths the file to `Extensions//`, fixes the namespace +- **Move extensions class to conventional location** (or **Rename extensions class to '…'** when it is + already in place but not conventionally named) — offered on a single-receiver extensions class. It re-paths the file to `Extensions//`, fixes the namespace to the receiver's, and updates `using` directives in other referencing documents so the move compiles. diff --git a/skills/purview-buildsdk-project-layout/SKILL.md b/skills/purview-buildsdk-project-layout/SKILL.md index 5550808..e339bd1 100644 --- a/skills/purview-buildsdk-project-layout/SKILL.md +++ b/skills/purview-buildsdk-project-layout/SKILL.md @@ -40,7 +40,7 @@ introduce new structure when no established pattern exists. - The `.csproj` filename must equal its containing directory name, unless `DisableProjectFileNamingConventionCheck=true`. -- Source projects under `src/`, test projects under `tests/`. +- Source projects under `src/src/`, test projects under `src/tests/`, the solution in `src/`. - Place a new project beside similar ones — same language, layer and test type. - One test type per project by default. @@ -96,8 +96,8 @@ Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `Shared A new project should inherit the SDK's default set (`net10.0;net11.0`) rather than pin its own TFMs, so a lifecycle change is one SDK bump rather than an edit in every project. When a project genuinely needs a different set, select one with `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, -`All`) or declare `TargetFramework`/`TargetFrameworks` **unconditionally** — a `Condition` on a TFM is -not seen, because the SDK reads it from the project XML. A Roslyn component targets `netstandard2.0`. +`All`) or declare `TargetFramework`/`TargetFrameworks` — an explicit value always wins over every set, +with or without a `Condition`. A Roslyn component targets `netstandard2.0`. See `purview-buildsdk-configuration`. ## Automatic project references to preserve diff --git a/skills/purview-buildsdk-repository-review/SKILL.md b/skills/purview-buildsdk-repository-review/SKILL.md index 6fee41c..15f90c0 100644 --- a/skills/purview-buildsdk-repository-review/SKILL.md +++ b/skills/purview-buildsdk-repository-review/SKILL.md @@ -43,7 +43,7 @@ Inspect, in this order, before forming any opinion: ## 2. Check the structural signals -- Source projects under `src/`, test projects under `tests/`. +- Source projects under `src/src/`, test projects under `src/tests/`, the solution in `src/`. - `.csproj` filename equals its containing directory name — unless `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. - Short project names, with `NamespacePrefix` carrying the repository identity. @@ -56,9 +56,10 @@ Inspect, in this order, before forming any opinion: the behaviour. - Target frameworks: a project should inherit the SDK's default set (`net10.0;net11.0`) or select a curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`), not hard-code - a TFM list. A hard-coded list is an incremental opportunity; a `Condition`-guarded - `TargetFramework`/`TargetFrameworks` is structural debt, because the SDK reads the declaration from - the project XML and cannot evaluate the condition (`PurviewConditionedProjectDeclaration`). + a TFM list. A hard-coded list is an incremental opportunity. A TFM set in `Directory.Build.targets` + is structural debt: it is evaluated after the .NET SDK decides whether to multi-target, so it does + not take effect. A `Condition`-guarded `PurviewTargetFrameworkSet` is not seen at all + (`PurviewConditionedProjectDeclaration`). ## 3. Verify with MSBuild, do not infer from folder names @@ -97,18 +98,19 @@ go stale: ## 6. Check how agent skills are managed -Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package -reference that ships skills: +Only relevant when the repository has a `skills-lock.json` or an agent skills directory: - **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a - `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes - should only happen when somebody asks. + `just` recipe dependency — is structural debt: skill changes should only happen when somebody asks. + The SDK fails the build-time cases with `PRSKL0009` (inside a build, a restore or a design-time + build); a git hook or a recipe dependency that runs the target on its own is **not** caught, so + look for those by reading the hook configuration and the `Justfile`. - **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, - `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and - pin package skills to their package's commit where the scripts install latest. See - `purview-buildsdk-skills-justfile`. -- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets wrap the same + standard CLI, from the repository root, once per solution. See `purview-buildsdk-justfile`. +- **A fresh clone**: a committed `skills-lock.json` with no agent skills directory beside it means the + skills were never restored — `-t:InstallPurviewSkills` (`skills experimental_install`) restores them. - **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. diff --git a/skills/purview-buildsdk-skill-installation/SKILL.md b/skills/purview-buildsdk-skill-installation/SKILL.md index 5c2694d..545eb9f 100644 --- a/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,101 +1,86 @@ --- name: purview-buildsdk-skill-installation -description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, check and tidy agent skills in a repository that uses Purview.BuildSdk, through the standard skills CLI (bun x skills). Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills and FixPurviewSkills MSBuild targets, for installing skills from a repository's root skills/ folder (owner/repo#ref), for restoring skills-lock.json in a fresh clone, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), or when an agent cannot see a skill that should be installed. license: MIT metadata: package: Purview.BuildSdk repository: purview-dev/build-sdk --- -# Installing the skills a package ships +# Installing agent skills For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these -targets, `purview-buildsdk-skills-justfile`. +targets, `purview-buildsdk-justfile`. ## The one thing to understand first -**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but -as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing -copies it out. +**The SDK is a thin wrapper over the standard skills CLI, and adds nothing bespoke.** A repository +publishes skills in its root `skills/` folder; the skills CLI installs them with +`bun x skills add owner/repo` and records each install in `skills-lock.json`. The SDK's targets only +run that CLI — from the repository root, once per solution, never during a build — and keep the +agent directories tidy. There is no package-to-skill mapping: a NuGet package carries .NET artifacts, +and skills come from git. -Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. -Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming -repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The -mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a -lockfile ref against content the mirror had already replaced. The mirror was removed outright rather -than fenced off, because the CLI covers the whole job. - -Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a -restore, a build, a design-time build or a test run. +The SDK writes nothing into an agent directory itself. The CLI is the single writer, because its +universal install location is `.agents/skills/` and a second writer on the same paths would overwrite +a pinned install. ```bash -dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing -dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI -dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills -dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist -dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk # add a source +dotnet msbuild -t:InstallPurviewSkills # restore skills-lock.json (a fresh clone) +dotnet msbuild -t:UpdatePurviewSkills # re-add every lockfile source at its latest +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when -something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook -them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, -something has been wired up that should not be. - -Name **the solution**. Skills belong to the repository, so each target runs once however many -projects the solution holds: every project hands the work to the same elected leader, and MSBuild -runs it once. Package resolution reads every project in the solution, so a package only one project -references is still found. A single project works too, but sees only its own packages. For `just` -recipes that run these targets, see `purview-buildsdk-skills-justfile`. +something pulls them into a build, a restore or a design-time build (`PRSKL0009`). A git hook or a +`just` recipe dependency that runs one on its own is not caught by that — do not wire them up that way. -## How a skill is resolved, and why the version is always right +Name **the solution**. Skills belong to the repository, so each target runs once however many projects +the solution holds: every project hands the work to the same elected leader, and MSBuild runs it once. +A single project works too. -There is deliberately **no custom manifest**. A package that ships skills already carries both -halves of the mapping, and `ResolvePurviewSkillSources` reads them: +## Installing -- `.agents/skills//` in the restored package says *which* skills it ships. -- The nuspec's ``, written by SourceLink for every package this - SDK builds, says which git repository and which **exact commit** produced them. +| Command | Runs | +| -- | -- | +| `-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo` | `skills add "owner/repo" --skill "*"` — every skill in that repository's root `skills/` | +| `-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo#` | the same, pinned to a commit, tag or branch | +| `-t:InstallPurviewSkills` | `skills experimental_install` — restore exactly what `skills-lock.json` records | -So the resolved ref is the commit the *restored package version* was built from. An older -`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility -is exact by construction rather than by convention. +The caller owns the ref: append `#` to pin it. With no source and no `skills-lock.json` there is +nothing to install, which is `PRSKL0007`. `bun x skills add` works just as well run directly; the +target exists so a repository uses one runner, one root and one agent selection everywhere. ## Updating ```bash -# after bumping the PackageReference -dotnet restore -dotnet msbuild -t:UpdatePurviewSkills +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsDryRun=true # see the commands first +dotnet msbuild -t:UpdatePurviewSkills ``` -`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: - -- a source a restored package ships is re-added at **that package's commit**, naming every skill - that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the - package version referenced; -- every other source is re-added at its latest with `--skill "*"`. - -That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both -refreshes the installed skills and adds any the source has gained; `skills update` would only -re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every -command and runs none. Each failed source is named (`PRSKL0008`). - -`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its -package's commit. Do not reach for a bare `skills update` instead; see below. +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with +`skills add "" --skill "*"`, at its latest. That is the whole update: `skills add` reinstalls +every skill it selects, so one re-add both refreshes the installed skills and adds any the source has +gained, whereas `skills update` would only re-fetch what the lockfile already lists, and is never run. +Every source is attempted even when one fails, and each failure is named (`PRSKL0008`). No lockfile is +`PRSKL0007`. ## Choosing agents `PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect -the agents on the machine. Set it to choose — in a project file `universal`, -or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` -itself splits on `,` and `;`: +the agents on the machine. Set it to choose — in `Directory.Build.props` +(`universal`), or on a command line with the separator +escaped, because `dotnet msbuild -p:` itself splits on `,` and `;`: ```bash -dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex ``` -Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. A restore +(`experimental_install`) is passed no agent flag. When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always @@ -112,85 +97,21 @@ CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a legitimate destination. -## Installing from an explicit source - -For a skill you want but have no `PackageReference` for: - -```bash -dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk -``` - -The caller owns the ref here — append `#` to pin it. This path needs no restore, so -`PRSKL0001` does not apply. - ## Diagnostics | Code | Meaning | Fix | | -- | -- | -- | -| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | -| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | -| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=`, or add an `AGENTS.md` at the root | | `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | -| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0007` | Nothing to install or update: no `PurviewSkillsSource`, and no `skills-lock.json` | Install from a source first | | `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | | `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | -| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | -| `PRSKL0012` | A skill cannot be verified | Message | -| `PRSKL0013` | An orphaned lockfile entry | Message | -| `PRSKL0014` | Recorded in the lockfile but never restored | Message | | `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | | `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | -| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | - -## Nothing about skills runs during a build -This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. -It was removed after measurement — on a 39-project solution it read and regex-scanned every project's -`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` -calls, read the lockfile and walked the repository root, on every build, to print advice that a -repository managing its own skills had already acted on. All of its inputs are repository-scoped, so -the per-project cost bought nothing. - -The work is now paid only when somebody names a target: - -```bash -dotnet msbuild -t:RecommendPurviewSkills # report, never fail -dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 -``` - -`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own -`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. - -`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and -`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire -without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package -published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay -messages. Failing over something the caller cannot clear is how a gate gets switched off. - -Three things shape the output: - -- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes - unverifiable to a warning. -- A correctly installed set produces no output at all. -- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, - `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the - hook: a stamp existed so a per-build hook could report one state once per repository, and a target - a person invokes should answer every time it is asked. - -Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so -only an entry under an owner the project references — but from a repository it no longer references -— is reported. A skill from any other owner is the developer's own, and is never mentioned. - -### Drift never recommends a bare `skills update` - -Re-adding a source without a ref installs the *newest* published skill, which is only correct when -the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced -package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and -`-t:UpdatePurviewSkills` run for package skills. +`PRSKL0001`, `PRSKL0002`, `PRSKL0006`, `PRSKL0010`–`PRSKL0014` and `PRSKL0017` are retired: they +belonged to the package-to-skill mapping and recommendation targets, which are gone. ## Package runners @@ -205,17 +126,17 @@ The CLI is published to npm, and every runner spells "run this npm package" diff | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | +| `skills` | `skills` — already on `PATH`, no download | -An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. The launcher +pins the CLI to a major (`PurviewSkillsPackage`, default `skills@1`). `DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then `$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. `PATH` is read directly rather than by spawning a version check: deterministic, no process, and it cannot hang on a cold download. **The runner actually found wins over the configured preference.** -`PurviewSkillsCommand` overrides the launcher entirely and skips detection. - -`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and -reporting needs no Node tooling at all. +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. A dry run with no runner +on `PATH` prints the configured runner's command rather than failing. ## Always the repository root @@ -230,8 +151,7 @@ skill target runs from one resolved root: 6. SourceLink's git `SourceRoot` There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent -directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and -silence for recommendations — a build never fails over advice. +directory through the tree, one per project. An unresolved root is `PRSKL0003`. The marker walk is described in [references/repository-root.md](references/repository-root.md); its two-pass behaviour and its home-directory bound are both load-bearing. @@ -239,10 +159,6 @@ two-pass behaviour and its home-directory bound are both load-bearing. ## Why the CLI rather than a reimplementation The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent -and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `bun x skills experimental_install`. - -The lockfile is read **directly** rather than by parsing `skills list --json`, because that -listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs -no Node tooling, which is what keeps recommendations working on a machine that can build .NET but -has no Node. There is deliberately no competing lockfile. +and change independently of this SDK. It also owns `skills-lock.json`, which is what lets a fresh +clone restore the same set with `-t:InstallPurviewSkills` (`skills experimental_install`). There is +deliberately no competing lockfile and no competing metadata. diff --git a/skills/purview-buildsdk-skill-installation/references/repository-root.md b/skills/purview-buildsdk-skill-installation/references/repository-root.md index d556174..64cd1c4 100644 --- a/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -43,8 +43,8 @@ an agent directory somewhere that is not the repository. ## A target's Condition is evaluated before its DependsOnTargets A property a dependency computes is still empty in the depending target's `Condition`. That is why -the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +the unresolved-root check (`PRSKL0003`) is an `Error` inside a dependency target rather than a +`Condition` on the public target, and why the explicit-only guard (`PRSKL0009`) is a dependency target with its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills`. @@ -52,6 +52,7 @@ its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurv `Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same default markers, so a test that validates a repository's skills resolves the same root the build -installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion -on the default marker list itself — both are defaults nobody sets, so nothing else would notice them -drifting apart. +installs them into. In the Purview.BuildSdk repository the two are kept in step twice over: +`SkillRepositoryRootParityTests` runs the real MSBuild target and the managed port over the same +folders and compares the root and the matched marker, and `SkillRepositoryRootTests` pins the default +marker list itself — both are defaults nobody sets, so nothing else would notice them drifting apart. diff --git a/skills/purview-buildsdk-skill-tests/SKILL.md b/skills/purview-buildsdk-skill-tests/SKILL.md index 4043ac4..e6def7d 100644 --- a/skills/purview-buildsdk-skill-tests/SKILL.md +++ b/skills/purview-buildsdk-skill-tests/SKILL.md @@ -15,7 +15,6 @@ For installing skills a package ships, use `purview-buildsdk-skill-installation` **At the repository root, in `skills//SKILL.md`.** Always. -The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package carries .NET artifacts, and skills are delivered by the CLI from git. @@ -108,8 +107,8 @@ Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), `SkillsFolderName` (default `skills`), and `SkipRule(string)`. The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather -than a real repository — the same three-layer shape this repository uses throughout: a reader, an -evaluator over an in-memory model, and a thin test base class. +than a real repository: a reader (`SkillManifestReader`/`SkillCatalogue`), an evaluator over the +in-memory model (`SkillPolicyEvaluator`), and a thin test base class (`PurviewSkillTests`). ### At pack time — `ValidatePurviewSkills` diff --git a/skills/purview-buildsdk-skill-tests/references/authoring.md b/skills/purview-buildsdk-skill-tests/references/authoring.md index 69d4ee4..03d9bbf 100644 --- a/skills/purview-buildsdk-skill-tests/references/authoring.md +++ b/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -77,3 +77,5 @@ stale skill looks broken — it just gives confidently wrong advice. behaviour; those are greppable when the code changes. - Where a number or a list is authoritative somewhere else — a default, an enumeration — say where it comes from, so a reader can check it. +- To bring skills, agent instructions, READMEs and docs back in line with the code after a change, + use `purview-buildsdk-docs-sync`. diff --git a/skills/purview-buildsdk-skills-justfile/SKILL.md b/skills/purview-buildsdk-skills-justfile/SKILL.md deleted file mode 100644 index 2da36a2..0000000 --- a/skills/purview-buildsdk-skills-justfile/SKILL.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -name: purview-buildsdk-skills-justfile -description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. -license: MIT -metadata: - package: Purview.BuildSdk - repository: purview-dev/build-sdk ---- - -# Justfile recipes for the agent-skill targets - -The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are -covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` -correctly. - -## The rule that matters most - -**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` -and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a -restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: - -- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; -- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, - `.husky/`) or a CI job that commits; -- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. - -## Recipes - -Put them in their own file beside the `Justfile`, and import it from the `Justfile`: - -```just -# Justfile -import "Justfile.skills.just" -``` - -The only line that differs between repositories is `skills_project`. Work it out from the repository -as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not -leave a placeholder: - -```just -skills_project := solution_file # or a path, when the Justfile has no such variable - -# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones -[group('Agent skills')] -skills-update *args: - dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} - -# Check that only the skill directories of the agents in PurviewSkillsAgents exist -[group('Agent skills')] -skills-check *args: - dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} - -# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select -[group('Agent skills')] -skills-fix *args: - dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} - -# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) -[group('Agent skills')] -skills-install *args: - dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} - -# Report missing, drifted or unverifiable skills without changing anything -[group('Agent skills')] -skills-recommend *args: - dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} - -# Fail when a referenced package's skills are missing or drifted - the CI gate -[group('Agent skills')] -skills-verify *args: - dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} -``` - -Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. - -### Choosing `skills_project` - -Take the first of these that applies: - -1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` - or similar, typically used by `build`, `test` and `restore`. Reference it: - `skills_project := solution_file`. An imported file shares the importing file's variables, so the - skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. -2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that - path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference - that, but do not restructure the `Justfile` unasked. -3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring - `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to - the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that - still leaves more than one. -4. **No solution at all.** Name a project that imports the SDK and references every package that - ships skills, and say in a comment that it sees only its own packages. - -An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace -the project with the solution, chosen as above. Remove any comment explaining why that project was -picked, because the reason no longer holds. - -### Details that break a recipe if missed - -- **Name the solution.** Every skill target runs once for the whole solution, however many projects - it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package - resolution reads every project in the solution, so a package only a test project references is - still pinned. There is no project to choose and nothing to keep in step as projects come and go. - A single project also works, but sees only that project's packages. (SDK releases before this - behaviour ran the target once per project; there, name one project that references every package - that ships skills.) -- **Restore first.** The targets read `project.assets.json` and never restore on their own - (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. -- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats - the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. -- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. -- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so - it is safe in the recipe either way. - -## Runner and agents belong in MSBuild, not in the recipe - -Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, -so a plain `dotnet msbuild -t:...` behaves the same as the recipe: - -```xml - - - bun - - universal - -``` - -One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so -escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. - -Useful properties to pass through `*args`: - -| Property | Effect | -| -- | -- | -| `PurviewSkillsDryRun=true` | Print every CLI command; run none | -| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | -| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | - -## Replacing hand-written skill scripts - -Repositories often carry their own scripts for this. The SDK targets replace them: - -| Hand-written | Replace with | -| -- | -- | -| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | -| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | -| `--dry-run` | `-p:PurviewSkillsDryRun=true` | -| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | -| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | -| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | - -Two behaviours differ, deliberately — mention them when migrating: - -- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its - skills keep matching the package version. Other sources are re-added at latest, as before. -- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a - package source gets every skill its referenced version ships, and any other source is re-added with - `--skill "*"`. - -After switching, delete the scripts and their `package.json` entries, and remove `package.json` -itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the -printed commands with what the old script ran before deleting anything. - -## Updating an existing Justfile - -1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` - (a PowerShell shell still needs the `-t:` value quoted). -2. Keep recipe names the repository already uses; change only what they run. If `skills-check` - already means "report", rename it to `skills-recommend` rather than silently changing its meaning, - and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, - can point at the solution instead. -3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows - and hook configuration for the recipe name. -4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the - commands, and `just skills-check` passes or names a real problem. diff --git a/skills/purview-buildsdk-testing/SKILL.md b/skills/purview-buildsdk-testing/SKILL.md index 0ac749b..c7a7995 100644 --- a/skills/purview-buildsdk-testing/SKILL.md +++ b/skills/purview-buildsdk-testing/SKILL.md @@ -29,7 +29,7 @@ With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, A test project inherits the SDK's default target framework set (`net10.0;net11.0`). When it must match its subject — a project still on one TFM, or a subject that multi-targets — set -`TargetFramework`/`TargetFrameworks` on the **test** project, declared unconditionally, or select a +`TargetFramework`/`TargetFrameworks` on the **test** project (an explicit value always wins), or select a `PurviewTargetFrameworkSet`. See `purview-buildsdk-configuration`. Add nothing else unless the project's purpose demands it. Two that stay explicit: @@ -37,7 +37,8 @@ Add nothing else unless the project's purpose demands it. Two that stay explicit - `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests - `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, - Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Redis, MySQL, RabbitMQ, NATS, Azurite and the Azure Key Vault and Service Bus emulators. + Experimental and prerelease, so pin the version. Reference `Testcontainers` directly only where no typed module exists yet. ## Authoring rules @@ -88,7 +89,7 @@ they are what `--treenode-filter` property predicates select on. ```bash dotnet test # the whole suite dotnet test --treenode-filter "/*/*/LoginTests/*" # one class -dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +dotnet test --project --treenode-filter "/*/*/*/*[Category=Unit]" ``` Prefer the repository's own task (`just test`, or equivalent) for a full run. @@ -113,7 +114,8 @@ In order: ## Cross-platform requirement -CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. +A test that only passes on Windows fails on a Linux CI runner, so write every test to pass on Windows, +Linux and macOS. - Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no case-insensitive path assumptions. diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index 41c37ad..82126de 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -72,12 +72,13 @@ true true - + + false + @@ -95,8 +96,7 @@ skill directories exist. --> + target does. --> skills@1 + true false @@ -200,9 +204,8 @@ - - true + diff --git a/src/src/BuildSdk/Sdk/Props/VersionDetection.props b/src/src/BuildSdk/Sdk/Props/VersionDetection.props index 8e2f501..9937d64 100644 --- a/src/src/BuildSdk/Sdk/Props/VersionDetection.props +++ b/src/src/BuildSdk/Sdk/Props/VersionDetection.props @@ -36,8 +36,12 @@ >$(_CachedPackageJsonVersion) + - $([MSBuild]::NormalizePath('$(RootPackageJson)')) + $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(RootPackageJson)')) diff --git a/src/src/BuildSdk/Sdk/Sdk.props b/src/src/BuildSdk/Sdk/Sdk.props index 5e87453..fa373a3 100644 --- a/src/src/BuildSdk/Sdk/Sdk.props +++ b/src/src/BuildSdk/Sdk/Sdk.props @@ -428,7 +428,7 @@ Overrides how the skills CLI is invoked. Empty detects 'skills', then PurviewSkillsRunner, then PurviewSkillsRunnerCandidates on PATH. - Installs from an explicit git source (owner/repo, optionally '#ref') instead of from referenced packages. + The git source InstallPurviewSkills adds through the skills CLI (owner/repo, optionally '#ref'); unset restores skills-lock.json. Overrides where the skills CLI's skills-lock.json is read from. Defaults to the repository root. @@ -516,7 +516,7 @@ True when SdkProjectName is Microsoft.NET.Sdk.Worker. - True when the project SDK starts with Aspire.Sdk.Host. + True when the project SDK starts with Aspire.Sdk.Host or Aspire.AppHost.Sdk. True when SdkProjectName is Microsoft.NET.Sdk.BlazorWebAssembly. @@ -567,7 +567,7 @@ When true, a net11.0+ project compiles with the runtime-async compiler feature. Defaults to false; UseRuntimeAsync=true also opts in. - When true (default), boots a Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). + When true (default), boots a Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). Left unset, a Blazor WebAssembly client writes -mt:false instead. Path to the SDK-provided .editorconfig used for build-time style enforcement. @@ -591,7 +591,7 @@ When true, bootstraps a managed Directory.Build.rsp that enables MSBuild's multithreaded build (-mt). - When true, disables SDK auto-copy bootstrapping for repository files such as .editorconfig and global.json. + When true, disables SDK auto-copy bootstrapping for repository files: .editorconfig, global.json and Directory.Build.rsp. Version written to the msbuild-sdks Purview.BuildSdk entry when bootstrapping global.json. @@ -609,7 +609,7 @@ Name of the generated assembly identifier marker class. Derived from the project name with the namespace prefix stripped, suffixed with Assembly. - True when the project is a CLI project. + True when the project name ends with CLI, Console, CommandLine, QuickStart or QuickStarts. True when the project is a shared project. @@ -618,7 +618,7 @@ True for ServerShared helper projects (ServerSharedFramework, etc.), which are referenced automatically only by projects that cannot run in a browser. - True when the project's assemblies are downloaded to a browser: a WebAssembly client, a PurviewBrowserSafe library, a Razor class library, or a project named with a client-side suffix (*.Client, *.Browser, *.Wasm, *.WebAssembly, *.Components). Set explicitly to override. + True when the project's assemblies are downloaded to a browser: a WebAssembly client, a PurviewBrowserSafe library, a Razor class library, or a project named with a client-side suffix (*.Client, *.ClientShared, *.Browser, *.Wasm, *.WebAssembly, *.Components, or exactly ClientShared or Components). Set explicitly to override. @@ -640,7 +640,7 @@ Nullable reference-types context used to build the Roslyn component. Defaults to enable. - Treats all compiler/analyzer warnings as errors. Defaults to true for Roslyn components. + Treats all compiler, analyzer and NuGet warnings as errors. Defaults to true for every project; set false to opt out. Enforces Roslyn analyzer best-practice rules. Defaults to true for Roslyn components. @@ -656,6 +656,41 @@ + + + <_PurviewTargetFrameworkBeforeProbe>$(TargetFramework) + _purview-global-property-probe_ + <_PurviewTargetFrameworkIsGlobal Condition="'$(TargetFramework)' != '_purview-global-property-probe_'" + >true + $(_PurviewTargetFrameworkBeforeProbe) + <_PurviewTargetFrameworkSetBeforeImport + Condition="'$(TargetFramework)' != '' AND '$(_PurviewTargetFrameworkIsGlobal)' != 'true'" + >true + + + <_PurviewChainedBeforeMicrosoftNETSdkTargets>$(BeforeMicrosoftNETSdkTargets) + $(MSBuildThisFileDirectory)Targets/TargetFrameworkOwnership.targets + + @@ -668,11 +703,13 @@ netstandard2.0 + <_PurviewSdkAssignedTargetFramework Condition="'$(_PurviewProjectDeclaresTargetFramework)' != 'true'" + >netstandard2.0 true latest enable - true + + <_PurviewSdkAssignedTargetFramework + Condition="'$(TargetFramework)' == '' AND '$(_PurviewResolvedTargetFrameworkSet)' != '' AND !$(_PurviewResolvedTargetFrameworkSet.Contains(';'))" + >$(_PurviewResolvedTargetFrameworkSet) $(_PurviewResolvedTargetFrameworkSet) $(_PurviewResolvedTargetFrameworkSet) + + <_PurviewSdkAssignedTargetFrameworks + Condition="'$(_PurviewResolvedTargetFrameworkSet)' != '' AND $(_PurviewResolvedTargetFrameworkSet.Contains(';'))" + >$(_PurviewResolvedTargetFrameworkSet) @@ -817,6 +862,12 @@ quietly built one TFM and every multi-targeting consumer failed to reference it with "Project 'X' targets 'net10.0'. It cannot be referenced by a project that targets '.NETCoreApp,Version=v9.0'". --> + <_PurviewSdkAssignedTargetFrameworks + Condition="'$(TargetFramework)' == '' AND '$(TargetFrameworks)' == '' AND '$(_PurviewProjectDeclaresTargetFramework)' != 'true' AND $(PurviewTargetFrameworksCurrent.Contains(';'))" + >$(PurviewTargetFrameworksCurrent) + <_PurviewSdkAssignedTargetFramework + Condition="'$(TargetFramework)' == '' AND '$(TargetFrameworks)' == '' AND '$(_PurviewProjectDeclaresTargetFramework)' != 'true' AND !$(PurviewTargetFrameworksCurrent.Contains(';'))" + >$(PurviewTargetFrameworksCurrent) $(PurviewTargetFrameworksCurrent) diff --git a/src/src/BuildSdk/Sdk/Sdk.props.buildschema.json b/src/src/BuildSdk/Sdk/Sdk.props.buildschema.json new file mode 100644 index 0000000..124a5be --- /dev/null +++ b/src/src/BuildSdk/Sdk/Sdk.props.buildschema.json @@ -0,0 +1,637 @@ +{ + "$schema": "https://github.com/mhutch/MonoDevelop.MSBuildEditor/raw/main/MonoDevelop.MSBuild/Schemas/buildschema.json", + "license": "MIT. The MSBuild properties Purview.BuildSdk defines, for MSBuild Editor IntelliSense. Held to the code by PropertySchemaTests; see the purview-buildsdk-msbuild-properties skill before editing.", + "properties": { + "AssemblyIdentifierClassName": { + "description": "Name of the generated assembly identifier marker class. No default: derived from the project name with NamespacePrefix removed and dots stripped, suffixed with Assembly.", + "type": "clr-type-name" + }, + "AutoGeneratedAssemblyIdentifierFile": { + "description": "File name of the generated assembly identifier source file, written under the intermediate Properties/ folder.", + "type": "file", + "defaultValue": "GeneratedAssemblyIdentifier.cs" + }, + "AutoGeneratedAssemblyInfoFile": { + "description": "File name of the generated AssemblyInfo helper source file, written under the intermediate Properties/ folder.", + "type": "file", + "defaultValue": "GeneratedAssemblyInfo.cs" + }, + "AutoIncludeUsings": { + "description": "Adds global usings for NamespacePrefix and RootNamespace.", + "type": "bool", + "defaultValue": "true" + }, + "BootstrapEditorConfigToRepoRoot": { + "description": "Copies the SDK's .editorconfig to RepositoryEditorConfigFilePath when that file is missing.", + "type": "bool", + "defaultValue": "true" + }, + "BootstrapGlobalJsonToRepoRoot": { + "description": "Creates global.json at RepositoryGlobalJsonFilePath when it is missing, pinning msbuild-sdks.Purview.BuildSdk.", + "type": "bool", + "defaultValue": "true" + }, + "BootstrapMultiThreadedBuildRspToRepoRoot": { + "description": "Creates a managed Directory.Build.rsp at the repository root that enables MSBuild's multithreaded build (-mt) on .NET 11.", + "type": "bool", + "defaultValue": "true" + }, + "CurrentYear": { + "description": "Year used in generated assembly metadata. No default: the current year at build time.", + "type": "int" + }, + "DisableAutoCopySdkFiles": { + "description": "Master switch that turns off every repository bootstrap (.editorconfig, global.json, Directory.Build.rsp).", + "type": "bool", + "defaultValue": "false" + }, + "DisableAutoInternalsVisibleTo": { + "description": "Turns off the generated InternalsVisibleTo entries for test and shared-testing projects.", + "type": "bool", + "defaultValue": "false" + }, + "DisableGenerateAssemblyIdentifierClass": { + "description": "Turns off the generated assembly identifier marker class. True for test and shared-testing projects.", + "type": "bool", + "defaultValue": "false" + }, + "DisableGenerateAssemblyInfoClass": { + "description": "Turns off the generated AssemblyInfo helper source.", + "type": "bool", + "defaultValue": "false" + }, + "DisableNamespacePrefixCheck": { + "description": "Suppresses the build error raised when NamespacePrefix is not set.", + "type": "bool", + "defaultValue": "false" + }, + "DisableProjectFileNamingConventionCheck": { + "description": "Turns off the check that a project file is named after its folder (MyProject/MyProject.csproj).", + "type": "bool", + "defaultValue": "false" + }, + "DisablePurviewBlazorWebAssemblyDefaults": { + "description": "Keeps the generic defaults on a Blazor WebAssembly client (packable when asked, container detection, the automatic Shared reference). A ServerShared project is never added either way. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "false" + }, + "DisablePurviewBrowserSafeReferenceCheck": { + "description": "Turns off PRBLZ0001, the check that a Blazor WebAssembly client's (or browser-safe library's) project references set PurviewBrowserSafe. No default: unset, which is treated as false.", + "type": "bool" + }, + "DisablePurviewServerSharedReferenceCheck": { + "description": "Turns off PRSHR0001/PRSHR0002, which report a ServerShared reference from a client-side or Shared project. No default: unset, which is treated as false.", + "type": "bool" + }, + "DisablePurviewStylePolicyValidation": { + "description": "Stops PRSGD0006-PRSGD0009 failing the build when the repository .editorconfig or NoWarn weakens the style policy.", + "type": "bool", + "defaultValue": "false" + }, + "DisablePurviewTestContextRuleSet": { + "description": "Makes test and shared-testing projects enforce the production API-surface rules too, instead of exempting PurviewTestContextNoWarn. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "false" + }, + "DisableRoslynCompilerDefaultsValidation": { + "description": "Stops ValidateRoslynComponentCompilerSettings failing the pack of a Roslyn component whose compiler defaults were changed.", + "type": "bool", + "defaultValue": "false" + }, + "DisableSourceLink": { + "description": "Stops the SDK adding the SourceLink package at all.", + "type": "bool", + "defaultValue": "false" + }, + "DisableUnconditionalProjectDeclarationCheck": { + "description": "Suppresses PurviewConditionedProjectDeclaration, raised when IsRoslynComponent, IsRoslynComponentOnly, IsPackable, PurviewTargetFrameworkSet or RootNamespace is declared only under a Condition the SDK cannot evaluate.", + "type": "bool", + "defaultValue": "false" + }, + "EditorConfigFilePath": { + "description": "The SDK-provided .editorconfig injected into EditorConfigFiles for build-time style enforcement. Set it before the `Sdk.props` import. No default: the .editorconfig inside the installed SDK package.", + "type": "file" + }, + "EnableAssemblyNameGeneration": { + "description": "Derives AssemblyName and PackageId from the evaluated RootNamespace. False restores the standard .NET behaviour (the project name). An explicit AssemblyName or PackageId always wins. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "true" + }, + "EnablePurviewPropertySchemaValidation": { + "description": "Runs ValidatePurviewPropertySchema before Pack, failing it when a .buildschema.json sidecar and the package's .props/.targets disagree (PRPSC0001-PRPSC0004). For a package that ships MSBuild properties. Off, so no ordinary build or pack changes.", + "type": "bool", + "defaultValue": "false" + }, + "EnableVersionDetectionCache": { + "description": "Caches auto-discovered package.json version results locally. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "true" + }, + "ExcludeMSTelemetryExtension": { + "description": "Excludes Microsoft.Extensions.Telemetry.Abstractions. Only relevant while ExcludePurviewTelemetry is false.", + "type": "bool", + "defaultValue": "false" + }, + "ExcludePurviewTelemetry": { + "description": "Excludes Purview.Telemetry.SourceGenerator (and with it the whole telemetry package group) from every project. True for Roslyn components.", + "type": "bool", + "defaultValue": "false" + }, + "IsAspireHostProject": { + "description": "True when the project SDK starts with Aspire.Sdk.Host or Aspire.AppHost.Sdk. Set it before the `Sdk.props` import to override. No default: detected from the project.", + "type": "bool" + }, + "IsBffProject": { + "description": "True for a web SDK project whose name ends with Bff; set it to true or false to override. Gates the PDS0008/PDS0009 analyzers. No default: detected from the project.", + "type": "bool" + }, + "IsBlazorHostProject": { + "description": "True for a web SDK project that references a Blazor WebAssembly client. Says nothing about whether the host is a BFF. No default: detected from the project.", + "type": "bool" + }, + "IsBlazorWebAssemblyProject": { + "description": "True when the project SDK is Microsoft.NET.Sdk.BlazorWebAssembly. Set it before the `Sdk.props` import to override. No default: detected from the project.", + "type": "bool" + }, + "IsCLIProject": { + "description": "True when the project name ends with CLI, Console, CommandLine, QuickStart or QuickStarts. Set it before the `Sdk.props` import to override. No default: detected from the project.", + "type": "bool" + }, + "IsClientSideProject": { + "description": "True when the project's assemblies are downloaded to a browser: a WebAssembly client, a PurviewBrowserSafe library, a Razor class library, or a *.Client/*.ClientShared/*.Browser/*.Wasm/*.WebAssembly/*.Components (or ClientShared/Components) name. A ServerShared project is never added to one. Set it to override. No default: detected from the project.", + "type": "bool" + }, + "IsContainerProject": { + "description": "True when a Dockerfile beside the project selects the container defaults. Set it before the `Sdk.props` import to override. No default: detected from the project.", + "type": "bool" + }, + "IsCSharpProject": { + "description": "True for a .csproj; every C# default in the SDK is gated on it. Set it before the `Sdk.props` import. No default: detected from the project file extension.", + "type": "bool" + }, + "IsRazorClassLibraryProject": { + "description": "True when the project SDK is Microsoft.NET.Sdk.Razor. No default: detected from the project.", + "type": "bool" + }, + "IsRoslynComponentOnly": { + "description": "Produces an analyzer-only package for a Roslyn component: no lib/ output, no symbol package, and the analyzer and its PDB packed under analyzers/dotnet/cs/. True for Roslyn components; set false for a dual-role component. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "false" + }, + "IsSdkProject": { + "description": "True when an Sdk value is found on the project or its import declaration. No default: detected from the project.", + "type": "bool" + }, + "IsServerSharedProject": { + "description": "True for a ServerShared helper project (ServerShared, ServerSharedFramework, ...), which only projects that cannot run in a browser reference automatically. No default: detected from the project name.", + "type": "bool" + }, + "IsSharedProject": { + "description": "True for a Shared helper project (Shared, SharedFramework, ...), which other projects reference automatically. No default: detected from the project name.", + "type": "bool" + }, + "IsSharedTestingProject": { + "description": "True for a shared testing helper project (SharedTestingFramework, ...). Set it before the `Sdk.props` import. No default: detected from the project name.", + "type": "bool" + }, + "IsWebProject": { + "description": "Marks a project with web behaviour (a web SDK project or an API host). Set it before the `Sdk.props` import. No default: detected from the project.", + "type": "bool" + }, + "IsWebSdkProject": { + "description": "True when the project SDK is Microsoft.NET.Sdk.Web. Set it before the `Sdk.props` import. No default: detected from the project.", + "type": "bool" + }, + "IsWorkerSdkProject": { + "description": "True when the project SDK is Microsoft.NET.Sdk.Worker. No default: detected from the project.", + "type": "bool" + }, + "MemorySafetyRules": { + "description": "C# 15 memory-safety tier for net11.0+ targets. The updated rules are an experimental compiler feature, so they are opt-in. AllowUnsafeBlocks is never changed. An unrecognised tier fails with PRCFG0001.", + "type": { "$ref": "#/types/purview-memory-safety-rules" }, + "defaultValue": "0" + }, + "NamespacePrefix": { + "description": "Required. Root namespace prefix, for example Acme, which turns a project named MyProject into Acme.MyProject. Set it in Directory.Build.props, before the `Sdk.props` import. The build fails without it unless DisableNamespacePrefixCheck is true. No default: every repository chooses its own.", + "type": "clr-namespace" + }, + "PackProjectReferencedSourceGenerators": { + "description": "Packs analyzer ProjectReference outputs and their runtime dependencies under analyzers/dotnet/cs/. Set Pack=\"false\" on one reference to exclude only that generator. Set it before the `Sdk.props` import.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewAnalyzerClosurePermittedReferences": { + "description": "Simple-name prefixes of host-provided assemblies an analyzer-closure file may reference without PRSGD0005.", + "type": "string", + "isList": true, + "listSeparators": ";", + "defaultValue": "Microsoft.CodeAnalysis;System.Composition;System.Collections.Immutable;System.Memory;System.Threading.Tasks.Extensions;netstandard;mscorlib;System." + }, + "PurviewAutoBlazorClientReference": { + "description": "Lets a host named Name reference its Name.Client WebAssembly client automatically.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewAutoSdkPack": { + "description": "Packs a packable project's Sdk/ folder into the package, mapping Sdk/build, Sdk/buildTransitive and the rest to their package paths. Disable it for an MSBuild SDK project.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewAutoServerSharedReference": { + "description": "Adds ../ServerShared*/ServerShared*.csproj to a project that cannot run in a browser. Client-side projects never get one.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewBffExposesApi": { + "description": "Keeps the OpenAPI interceptor namespace on a BFF host. No default: true when the BFF references Microsoft.AspNetCore.OpenApi, otherwise unset.", + "type": "bool" + }, + "PurviewBrowserSafe": { + "description": "Applies the browser-safe defaults to a class library: IsAotCompatible, SupportedPlatform browser, and trim, AOT and CA1416 diagnostics as errors. No default: unset, which is treated as false.", + "type": "bool" + }, + "PurviewBrowserSafeReferenceCheckAsError": { + "description": "Reports PRBLZ0001 as an error instead of a warning. No default: unset, which is treated as false.", + "type": "bool" + }, + "PurviewBrowserSafeWarningCodes": { + "description": "Diagnostics a browser-safe library escalates to errors. No default: on a PurviewBrowserSafe library, CA1416, IL2001-IL2128, IL3000-IL3005 and IL3050-IL3059.", + "type": "warning-code", + "isList": true, + "listSeparators": ";" + }, + "PurviewBrowserSafeWarningsAsErrors": { + "description": "When false, a browser-safe library keeps its trim, AOT and CA1416 diagnostics as warnings. No default: true on a PurviewBrowserSafe library, otherwise unset.", + "type": "bool" + }, + "PurviewBuildSdkVersionForGlobalJson": { + "description": "Version written to msbuild-sdks.Purview.BuildSdk in a bootstrapped global.json. No default: the restored package version, else the version the SDK was packed as.", + "type": "nuget-version" + }, + "PurviewEditorConfigAdoptionStampPath": { + "description": "Where the once-per-SDK-version record of the .editorconfig adoption warning is kept. No default: .purview/editorconfig-adoption.cache beside the repository .editorconfig.", + "type": "file" + }, + "PurviewEditorConfigAdoptionWarning": { + "description": "Warns, once per repository per SDK version, when the repository .editorconfig is not one the SDK manages and so will never be updated.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewEditorConfigAdoptionWarningInCI": { + "description": "Reports the .editorconfig adoption warning on CI builds too. Off so that advice cannot fail a TreatWarningsAsErrors build.", + "type": "bool", + "defaultValue": "false" + }, + "PurviewEditorConfigAutoUpdate": { + "description": "Refreshes a managed .editorconfig (one opening with the SDK's version marker) when the SDK version changes. False keeps it exactly as it is.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewImportProjectReferenceBuildTransitive": { + "description": "Registers the CompilerVisibleProperty items a referenced project ships in Sdk/buildTransitive, so a package's own properties reach analyzers while developing it through ProjectReference.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewIncludeComponentReferenceAnalyzerClosure": { + "description": "Includes a referenced Roslyn component's analyzer artifact in the referencing component's analyzer closure.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewMultiThreadedBuild": { + "description": "When false, a managed Directory.Build.rsp is rewritten to -mt:false so MSBuild's multithreaded build is actually off. Left unset, a Blazor WebAssembly client writes -mt:false for the whole repository (it cannot build under -mt; see PRBLZ0002) and other projects keep that while the client exists; a value set explicitly always wins.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewPackAnalyzerPdb": { + "description": "Packs a Roslyn component's PDB beside its analyzer under analyzers/dotnet/cs/, because a .snupkg cannot carry analyzer symbols. No default: true for an analyzer-only Roslyn component, otherwise unset.", + "type": "bool" + }, + "PurviewPropertySchemaDirectory": { + "description": "Folder whose .props/.targets files and .buildschema.json sidecars ValidatePurviewPropertySchema checks. No default: the project's Sdk/ folder, which PurviewAutoSdkPack ships.", + "type": "folder" + }, + "PurviewPropertySchemaIgnoredProperties": { + "description": "Compiler-visible properties ValidatePurviewPropertySchema does not require a sidecar entry for, because the package does not own them (Version, RootNamespace, ...). No default: empty.", + "type": "string", + "isList": true, + "listSeparators": ";," + }, + "PurviewRepoBootstrapCopyFailureAsError": { + "description": "When false, a bootstrap write that still fails after every retry is a warning instead of an error.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewRepoBootstrapCopyRetries": { + "description": "Write attempts before a bootstrap write failure is reported.", + "type": "int", + "defaultValue": "3" + }, + "PurviewRepoBootstrapCopyRetryDelayMilliseconds": { + "description": "Base delay, in milliseconds, between bootstrap write attempts.", + "type": "int", + "defaultValue": "500" + }, + "PurviewRepoBootstrapMode": { + "description": "How the repository bootstrap treats an existing file.", + "type": { "$ref": "#/types/purview-repo-bootstrap-mode" }, + "defaultValue": "IfMissing" + }, + "PurviewRepositoryRootMarkers": { + "description": "Ordered markers that identify a repository root. The first is definitive and searched to the top; the rest resolve nearest-first. A trailing slash marks a directory. The home directory and above are never candidates.", + "type": "string", + "isList": true, + "listSeparators": ";", + "defaultValue": ".git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json" + }, + "PurviewRepositoryRootStrategy": { + "description": "How the bootstrap finds the repository root.", + "type": { "$ref": "#/types/purview-repository-root-strategy" }, + "defaultValue": "Markers" + }, + "PurviewRuntimeAsync": { + "description": "Compiles net11.0+ targets with runtime-async=on. UseRuntimeAsync=true also opts in; an explicit false under either name keeps it off. No default: unset, which is treated as false.", + "type": "bool" + }, + "PurviewServerSharedReferenceCheckAsError": { + "description": "Reports PRSHR0001/PRSHR0002 as errors instead of warnings.", + "type": "bool", + "defaultValue": "false" + }, + "PurviewSharedTestingOutputType": { + "description": "Output type forced on shared-testing projects. Library keeps them helper libraries, with IsTestProject and IsTestingPlatformApplication cleared; Exe keeps the test packages' executable shape.", + "type": { "$ref": "#/types/purview-shared-testing-output-type" }, + "defaultValue": "Library" + }, + "PurviewSkillsAgents": { + "description": "Agents the skill targets install to, separated by commas, semicolons or spaces. When set, CheckPurviewSkills and FixPurviewSkills allow only those agents' skill directories. On a dotnet msbuild command line write a comma as %2C. No default: empty, so the skills CLI auto-detects agents.", + "type": "string", + "isList": true, + "listSeparators": ";," + }, + "PurviewSkillsCommand": { + "description": "Replaces the skills CLI launcher entirely, skipping runner detection. No default: detected from PATH (skills, then PurviewSkillsRunner, then PurviewSkillsRunnerCandidates).", + "type": "string" + }, + "PurviewSkillsDryRun": { + "description": "Makes InstallPurviewSkills and UpdatePurviewSkills print the skills CLI commands without running them. No default: unset, which is treated as false.", + "type": "bool" + }, + "PurviewSkillsInstallRoot": { + "description": "Overrides the repository root every skill target runs from. No default: resolved from PurviewRepositoryRootMarkers, CI workspace variables and SourceLink.", + "type": "folder" + }, + "PurviewSkillsLockFile": { + "description": "Where the skills CLI's lockfile is read from. No default: skills-lock.json at the resolved repository root.", + "type": "file" + }, + "PurviewSkillsPackage": { + "description": "Package spec the executed skills CLI launcher pins to.", + "type": "string", + "defaultValue": "skills@1" + }, + "PurviewSkillsRepoRoot": { + "description": "Evaluation-time guess at the repository root for the skill targets; a value you set outranks the marker walk. Set it before the `Sdk.props` import. No default: RepoRoot, else the nearest folder with an AGENTS.md.", + "type": "folder" + }, + "PurviewSkillsRunner": { + "description": "Package runner used to launch the skills CLI. A runner without a known launcher runs as ' skills@1'.", + "type": { "$ref": "#/types/purview-skills-runner" }, + "defaultValue": "bun" + }, + "PurviewSkillsRunnerCandidates": { + "description": "Runners probed on PATH, in order, after PurviewSkillsRunner.", + "type": "string", + "isList": true, + "listSeparators": ";", + "defaultValue": "bun;npx;bunx;pnpm;yarn;deno" + }, + "PurviewSkillsSource": { + "description": "The git source InstallPurviewSkills adds through the skills CLI (owner/repo, optionally #ref). No default: unset, which makes InstallPurviewSkills restore skills-lock.json instead.", + "type": "string" + }, + "PurviewSkillsSourceFolder": { + "description": "Repository folder holding authored skills. No default: skills/ under PurviewSkillsRepoRoot.", + "type": "folder" + }, + "PurviewSourceGeneratorFrameworkAnalyzerValidation": { + "description": "Set to false to turn off PRSGD0005, which checks that every file in a Roslyn component's analyzer closure is self-contained. No default: unset, which is treated as true.", + "type": "bool" + }, + "PurviewSuppressCopyRetryWarnings": { + "description": "Demotes the built-in copy task's MSB3026 retry notices to messages. A copy that finally fails is still an error.", + "type": "bool", + "defaultValue": "true" + }, + "PurviewTargetFrameworkSet": { + "description": "Curated target framework set used when no TargetFramework or TargetFrameworks is set. An explicit TargetFramework(s) always wins. Set it before the `Sdk.props` import, or unconditionally in the project file. An unknown name fails with PurviewInvalidTargetFrameworkSet; a Roslyn component ignores the sets.", + "type": { "$ref": "#/types/purview-target-framework-set" }, + "defaultValue": "Current" + }, + "PurviewTargetFrameworksAll": { + "description": "Frameworks of the All set: Broad plus the newest major. Set it before the `Sdk.props` import to redefine the set.", + "type": "target-framework", + "isList": true, + "listSeparators": ";", + "defaultValue": "net8.0;net9.0;net10.0;net11.0" + }, + "PurviewTargetFrameworksBroad": { + "description": "Frameworks of the Broad set: every shipped release, without committing to the newest major. Set it before the `Sdk.props` import to redefine the set.", + "type": "target-framework", + "isList": true, + "listSeparators": ";", + "defaultValue": "net8.0;net9.0;net10.0" + }, + "PurviewTargetFrameworksCurrent": { + "description": "Frameworks of the Current set, the default: the current LTS plus the latest release. Set it before the `Sdk.props` import to redefine the set.", + "type": "target-framework", + "isList": true, + "listSeparators": ";", + "defaultValue": "net10.0;net11.0" + }, + "PurviewTargetFrameworksLatest": { + "description": "Frameworks of the Latest set: the latest shipped release only. Set it before the `Sdk.props` import to redefine the set.", + "type": "target-framework", + "isList": true, + "listSeparators": ";", + "defaultValue": "net11.0" + }, + "PurviewTargetFrameworksSupported": { + "description": "Frameworks of the Supported set: every release still in support. Set it before the `Sdk.props` import to redefine the set.", + "type": "target-framework", + "isList": true, + "listSeparators": ";", + "defaultValue": "net10.0;net11.0" + }, + "PurviewTestContextNoWarn": { + "description": "Rules exempted in test and shared-testing projects, so test classes stay public, Method_Scenario_Expectation names keep their underscores and helpers need no null guards. Set it before the `Sdk.props` import to narrow or extend it.", + "type": "warning-code", + "isList": true, + "listSeparators": ";", + "defaultValue": ";CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707;" + }, + "RepoRoot": { + "description": "Repository root used for version detection and bootstrapping. Set it before the `Sdk.props` import. No default: discovered from CI workspace variables, then a .git marker or package.json above the project.", + "type": "folder" + }, + "RepositoryDirectoryBuildRspFilePath": { + "description": "Destination of the bootstrapped Directory.Build.rsp. No default: resolved at the repository root.", + "type": "file" + }, + "RepositoryEditorConfigFilePath": { + "description": "Destination of the bootstrapped .editorconfig. No default: resolved at the repository root, else beside Directory.Build.props.", + "type": "file" + }, + "RepositoryGlobalJsonFilePath": { + "description": "Destination of the bootstrapped global.json. No default: resolved at the repository root, else beside Directory.Build.props.", + "type": "file" + }, + "RootPackageJson": { + "description": "Explicit path to the package.json whose version becomes Version and PackageVersion. A relative path resolves from the project directory, so in Directory.Build.props prefer $(MSBuildThisFileDirectory)package.json. Set it before the `Sdk.props` import. No default: auto-discovered at the repository root.", + "type": "file" + }, + "SdkProjectName": { + "description": "The SDK named by the project or its import, for example Microsoft.NET.Sdk.Web. Set it before the `Sdk.props` import. No default: detected from the project.", + "type": "sdk" + }, + "SourceLinkPackageName": { + "description": "SourceLink provider package added to every non-test project. Match the repository host, and give it a PackageVersion under Central Package Management.", + "type": { "$ref": "#/types/purview-sourcelink-package" }, + "defaultValue": "Microsoft.SourceLink.GitHub" + }, + "SubstituteFramework": { + "description": "Mocking provider added to test projects. Set it before the `Sdk.props` import.", + "type": { "$ref": "#/types/purview-substitute-framework" }, + "defaultValue": "TUnitMocks" + }, + "TargetProjectName": { + "description": "For a test project, the project under test, inferred from the test project's name. No default: detected from the project name.", + "type": "string" + }, + "TestDataFramework": { + "description": "Test data provider added to test projects. Set it before the `Sdk.props` import.", + "type": { "$ref": "#/types/purview-test-data-framework" }, + "defaultValue": "Bogus" + }, + "TestingFramework": { + "description": "Testing framework wired into test projects. Set it before the `Sdk.props` import.", + "type": { "$ref": "#/types/purview-testing-framework" }, + "defaultValue": "TUnit" + }, + "TestingType": { + "description": "Test category taken from a test project's name suffix (Unit, Integration, E2E, ...). Set it before the `Sdk.props` import to override. No default: detected from the project name.", + "type": "string" + }, + "UsePackageJsonVersion": { + "description": "Version detection from package.json: true applies the version field to Version and PackageVersion, false turns it off, and Strict fails the build when no version source can be resolved. Set it before the `Sdk.props` import.", + "type": { "$ref": "#/types/purview-package-json-version" }, + "defaultValue": "true" + }, + "VersionDetectionCacheFile": { + "description": "File the package.json version detection cache is written to. Set it before the `Sdk.props` import. No default: one file per project directory under the temporary directory.", + "type": "file" + }, + "VersionDetectionLogEnabled": { + "description": "Emits a high-importance message with the detected package version.", + "type": "bool", + "defaultValue": "false" + } + }, + "types": { + "purview-memory-safety-rules": { + "description": "A C# 15 memory-safety tier.", + "values": { + "0": "Off.", + "1": "Preview pointer syntax; also selects LangVersion=preview.", + "2": "The updated memory-safety rules (updated-memory-safety-rules)." + } + }, + "purview-package-json-version": { + "description": "Whether the version comes from package.json.", + "values": { + "true": "Use the package.json version when one can be found.", + "false": "Do not read package.json.", + "Strict": "Use the package.json version, and fail the build when none can be resolved." + } + }, + "purview-repo-bootstrap-mode": { + "description": "How a repository bootstrap treats an existing file.", + "values": { + "IfMissing": "Write the file only when it does not exist.", + "Always": "Overwrite the file on every build.", + "WarnOnDrift": "Report a file that differs from the SDK's.", + "Never": "Skip bootstrapping." + } + }, + "purview-repository-root-strategy": { + "description": "How the repository root is found.", + "values": { + "Markers": "Walk up to the nearest root marker, without starting a process.", + "Git": "Ask git for the worktree root." + } + }, + "purview-shared-testing-output-type": { + "description": "The output type of a shared-testing project.", + "values": { + "Library": "A helper library.", + "Exe": "An executable test host." + } + }, + "purview-skills-runner": { + "description": "A package runner that can launch the skills CLI.", + "allowUnknownValues": true, + "values": { + "bun": "bun x skills", + "npx": "npx -y skills", + "bunx": "bunx skills", + "pnpm": "pnpm dlx skills", + "yarn": "yarn dlx skills (Yarn 2 or later)", + "deno": "deno run -A npm:skills", + "skills": "A globally installed skills CLI." + } + }, + "purview-sourcelink-package": { + "description": "A SourceLink provider package.", + "allowUnknownValues": true, + "values": { + "Microsoft.SourceLink.GitHub": "GitHub.", + "Microsoft.SourceLink.AzureRepos.Git": "Azure DevOps Services.", + "Microsoft.SourceLink.AzureDevOpsServer.Git": "On-premises Azure DevOps Server or TFS.", + "Microsoft.SourceLink.GitLab": "GitLab.", + "Microsoft.SourceLink.Bitbucket.Git": "Bitbucket." + } + }, + "purview-substitute-framework": { + "description": "A mocking provider.", + "values": { + "TUnitMocks": "TUnit.Mocks.", + "NSubstitute": "NSubstitute, with its analyzers.", + "None": "No mocking provider." + } + }, + "purview-target-framework-set": { + "description": "A curated target framework set.", + "values": { + "Latest": "The latest shipped release only.", + "Current": "The current LTS plus the latest release.", + "Supported": "Every release still in support.", + "Broad": "Every shipped release, without the newest major.", + "All": "Every shipped release, including the newest major." + } + }, + "purview-test-data-framework": { + "description": "A test data provider.", + "values": { + "Bogus": "Bogus.", + "None": "No test data provider." + } + }, + "purview-testing-framework": { + "description": "A testing framework.", + "values": { + "TUnit": "TUnit on Microsoft.Testing.Platform.", + "Xunit": "xUnit v3.", + "None": "No testing framework." + } + } + } +} diff --git a/src/src/BuildSdk/Sdk/Sdk.targets b/src/src/BuildSdk/Sdk/Sdk.targets index b60a2f7..0d64f78 100644 --- a/src/src/BuildSdk/Sdk/Sdk.targets +++ b/src/src/BuildSdk/Sdk/Sdk.targets @@ -11,6 +11,7 @@ + - - - - - - - - - - - - - - - - - - (); - var unresolved = new List(); - // One package at one version is one result, however many projects reference it. - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - var roots = (NuGetPackageFolders ?? string.Empty) - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) - .Select(root => root.TrimEnd('\\', '/')) - .Where(root => root.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - // Same "libraries" shape DiscoverAgentPackageFolders reads; the version is the - // segment after the id, and is reported so a diagnostic can name it. - var libraryPattern = new Regex( - "\"(?[^\"/]+)/(?[^\"]+)\"\\s*:\\s*\\{[^{}]*?\"type\"\\s*:\\s*\"package\"[^{}]*?\"path\"\\s*:\\s*\"(?[^\"]+)\"", - RegexOptions.Singleline - ); - // NuGet writes the repository element on one line; url and commit may appear in - // either order, so match the element then pull each attribute out of it. - var repositoryElementPattern = new Regex("]*/?>", RegexOptions.IgnoreCase); - var urlPattern = new Regex("\\burl\\s*=\\s*\"(?[^\"]*)\"", RegexOptions.IgnoreCase); - var commitPattern = new Regex("\\bcommit\\s*=\\s*\"(?[^\"]*)\"", RegexOptions.IgnoreCase); - - var assetsFiles = (ProjectAssetsFiles ?? string.Empty) - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) - .Select(file => file.Trim()) - .Where(file => file.Length > 0 && File.Exists(file)) - .Distinct(StringComparer.OrdinalIgnoreCase); - - foreach (var assetsFile in assetsFiles) - { - var json = File.ReadAllText(assetsFile); - - foreach (Match match in libraryPattern.Matches(json)) - { - var libraryPath = match.Groups["path"].Value; - if (string.IsNullOrEmpty(libraryPath)) - continue; - - var packageId = match.Groups["id"].Value; - var packageVersion = match.Groups["version"].Value; - if (!seen.Add(packageId + "/" + packageVersion)) - continue; - var normalizedPath = libraryPath.Replace('/', Path.DirectorySeparatorChar); - - foreach (var root in roots) - { - var packageRoot = Path.Combine(root, normalizedPath); - var skillsFolder = Path.Combine(packageRoot, ".agents", "skills"); - if (!Directory.Exists(skillsFolder)) - continue; - - // Only a folder holding a SKILL.md is a skill; a 'references' or - // 'scripts' folder beside one is part of it, not another skill. - var skillNames = Directory.GetDirectories(skillsFolder) - .Where(dir => File.Exists(Path.Combine(dir, "SKILL.md"))) - .Select(dir => Path.GetFileName(dir)) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (skillNames.Length == 0) - break; - - var repositoryUrl = string.Empty; - var repositoryCommit = string.Empty; - var nuspecFiles = Directory.GetFiles(packageRoot, "*.nuspec"); - if (nuspecFiles.Length > 0) - { - var nuspec = File.ReadAllText(nuspecFiles[0]); - var element = repositoryElementPattern.Match(nuspec); - if (element.Success) - { - var urlMatch = urlPattern.Match(element.Value); - if (urlMatch.Success) - repositoryUrl = urlMatch.Groups["url"].Value; - - var commitMatch = commitPattern.Match(element.Value); - if (commitMatch.Success) - repositoryCommit = commitMatch.Groups["commit"].Value; - } - } - - // The skills CLI addresses GitHub as 'owner/repo'; a trailing slash or a - // '.git' suffix is normal in nuspec metadata and must be trimmed off. - var source = string.Empty; - if (repositoryUrl.Length > 0) - { - var gitHubMatch = Regex.Match( - repositoryUrl, - "github\\.com[/:](?[^/]+)/(?[^/]+?)(?:\\.git)?/?$", - RegexOptions.IgnoreCase - ); - if (gitHubMatch.Success) - source = gitHubMatch.Groups["owner"].Value + "/" + gitHubMatch.Groups["repo"].Value; - } - - var item = new TaskItem(packageId); - item.SetMetadata("PackageId", packageId); - item.SetMetadata("PackageVersion", packageVersion); - item.SetMetadata("Skills", string.Join(";", skillNames)); - item.SetMetadata("SkillCount", skillNames.Length.ToString()); - item.SetMetadata("PackageSkillsFolder", skillsFolder); - item.SetMetadata("RepositoryUrl", repositoryUrl); - item.SetMetadata("RepositoryCommit", repositoryCommit); - item.SetMetadata("Source", source); - - if (source.Length > 0 && repositoryCommit.Length > 0) - { - // '/#' is the CLI's pinned-source syntax, and the - // commit is recorded in its lockfile so a restore is reproducible. - item.SetMetadata("SourceWithRef", source + "#" + repositoryCommit); - resolved.Add(item); - } - else - { - item.SetMetadata( - "Reason", - source.Length == 0 - ? "the package's nuspec has no GitHub metadata" - : "the package's nuspec element has no commit attribute" - ); - unresolved.Add(item); - } - - break; - } - } - } - - VersionConflicts = resolved - .Concat(unresolved) - .GroupBy(item => item.GetMetadata("PackageId"), StringComparer.OrdinalIgnoreCase) - .Where(group => group.Count() > 1) - .Select(group => - { - var conflict = new TaskItem(group.Key); - conflict.SetMetadata( - "Versions", - string.Join(", ", group.Select(item => item.GetMetadata("PackageVersion"))) - ); - return (ITaskItem)conflict; - }) - .ToArray(); - - SkillSources = resolved.ToArray(); - Unresolvable = unresolved.ToArray(); - ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - agent.Trim()) - .Where(agent => agent.Length > 0) - .Select(agent => " --agent \"" + agent + "\"") - ); - // How the repository launches the CLI - 'bun x skills', 'npx skills', and so on. - var cli = string.IsNullOrEmpty(CommandPrefix) ? "bun x skills" : CommandPrefix; - - // ---- Read the CLI's lockfile ------------------------------------------------- - // Entries are flat objects, so one regex over the 'skills' object is enough. A - // shape this does not understand degrades to "not installed", which only ever - // produces advice, never a failure. - var lockedSource = new Dictionary(StringComparer.OrdinalIgnoreCase); - var lockedRef = new Dictionary(StringComparer.OrdinalIgnoreCase); - - LockFileFound = !string.IsNullOrEmpty(LockFilePath) && File.Exists(LockFilePath); - if (LockFileFound) - { - var lockText = string.Empty; - try { lockText = File.ReadAllText(LockFilePath); } - catch (IOException) { LockFileFound = false; } - catch (UnauthorizedAccessException) { LockFileFound = false; } - - var skillsKey = lockText.IndexOf("\"skills\"", StringComparison.Ordinal); - var open = skillsKey < 0 ? -1 : lockText.IndexOf('{', skillsKey); - if (open >= 0) - { - // Walk to the matching brace rather than regexing the whole document, so a - // sibling key that happens to contain braces cannot be swept in. - var depth = 0; - var inString = false; - var escaped = false; - var close = -1; - for (var index = open; index < lockText.Length; index++) - { - var character = lockText[index]; - if (escaped) { escaped = false; continue; } - if (character == '\\' && inString) { escaped = true; continue; } - if (character == '"') { inString = !inString; continue; } - if (inString) continue; - if (character == '{') depth++; - else if (character == '}') - { - depth--; - if (depth == 0) { close = index; break; } - } - } - - if (close > open) - { - var body = lockText.Substring(open, close - open + 1); - var entryPattern = new Regex("\"(?[^\"]+)\"\\s*:\\s*\\{(?[^{}]*)\\}", RegexOptions.Singleline); - // '"source"' cannot match '"sourceType"': the closing quote differs. - var sourcePattern = new Regex("\"source\"\\s*:\\s*\"(?[^\"]*)\""); - var refPattern = new Regex("\"ref\"\\s*:\\s*\"(?[^\"]*)\""); - - foreach (Match entry in entryPattern.Matches(body)) - { - var name = entry.Groups["name"].Value; - var text = entry.Groups["entry"].Value; - var sourceMatch = sourcePattern.Match(text); - var refMatch = refPattern.Match(text); - lockedSource[name] = sourceMatch.Success ? sourceMatch.Groups["value"].Value : string.Empty; - lockedRef[name] = refMatch.Success ? refMatch.Groups["value"].Value : string.Empty; - } - } - } - } - - // ---- Has the lockfile ever been restored here? ------------------------------- - // A lockfile is a record of an install, not proof of one: a fresh clone carries a - // committed skills-lock.json and no installed skills at all. Rather than hard-code - // the agent directories - which the CLI owns, and which change independently of - // this SDK - look for any dot-folder at the repository root holding a 'skills' - // folder. - // - // '.agents' is deliberately *included* in that scan. It used to be excluded, - // because the SDK's own mirror wrote there and finding it proved nothing. The - // mirror is gone, so '.agents/skills/' is now unambiguously the CLI's universal - // install location - excluding it would report PRSKL0014 ("no agent skill - // directory exists") against a repository whose skills are correctly installed. - AgentSkillDirectoriesFound = false; - if (!string.IsNullOrEmpty(RepositoryRoot) && Directory.Exists(RepositoryRoot)) - { - // Neither of these is ever an agent directory, and '.purview' is this SDK's own. - var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) - { - ".git", - ".purview", - }; - - foreach (var directory in Directory.GetDirectories(RepositoryRoot, ".*")) - { - if (ignored.Contains(Path.GetFileName(directory))) - continue; - - if (Directory.Exists(Path.Combine(directory, "skills"))) - { - AgentSkillDirectoriesFound = true; - break; - } - } - } - - // ---- Classify ---------------------------------------------------------------- - var missing = new List(); - var drifted = new List(); - var unknown = new List(); - var satisfied = new List(); - var orphaned = new List(); - - var knownSources = new HashSet(StringComparer.OrdinalIgnoreCase); - var knownOwners = new HashSet(StringComparer.OrdinalIgnoreCase); - var claimedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var item in sources.Concat(unpinnable)) - { - var source = item.GetMetadata("Source") ?? string.Empty; - if (source.Length == 0) - continue; - - knownSources.Add(source); - var slash = source.IndexOf('/'); - if (slash > 0) - knownOwners.Add(source.Substring(0, slash)); - } - - foreach (var item in sources) - { - var packageId = item.GetMetadata("PackageId"); - var packageVersion = item.GetMetadata("PackageVersion"); - var source = item.GetMetadata("Source"); - var commit = item.GetMetadata("RepositoryCommit"); - var sourceWithRef = item.GetMetadata("SourceWithRef"); - - var names = (item.GetMetadata("Skills") ?? string.Empty) - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var missingNames = new List(); - var driftedNames = new List(); - var unknownNames = new List(); - var unknownReasons = new List(); - var satisfiedNames = new List(); - - foreach (var name in names) - { - claimedNames.Add(name); - - string installedSource; - if (!lockedSource.TryGetValue(name, out installedSource)) - { - missingNames.Add(name); - continue; - } - - // Same name, different repository: somebody installed their own skill under - // this name. Reporting it as drift would be wrong, and overwriting it is not - // this SDK's call, so it is surfaced as unknown and left alone. - if (!string.Equals(installedSource, source, StringComparison.OrdinalIgnoreCase)) - { - unknownNames.Add(name); - unknownReasons.Add(name + " is installed from '" + installedSource + "', not '" + source + "'"); - continue; - } - - var installedRef = lockedRef[name]; - if (installedRef.Length == 0) - { - unknownNames.Add(name); - unknownReasons.Add(name + " is installed with no ref recorded, so it cannot be matched to a package version"); - } - else if (string.Equals(installedRef, commit, StringComparison.OrdinalIgnoreCase)) - satisfiedNames.Add(name); - else - driftedNames.Add(name); - } - - // The install command names each skill explicitly and repeats the flag: the CLI - // matches nothing when given one comma-separated value. - var commandFor = new Func, string>(selected => - cli + " add \"" + sourceWithRef + "\" --skill \"" - + string.Join("\" --skill \"", selected) + "\"" + agentArguments + " -y"); - - var addItem = new Func, List, ITaskItem>((bucket, selected) => - { - var created = new TaskItem(packageId); - created.SetMetadata("PackageId", packageId); - created.SetMetadata("PackageVersion", packageVersion); - created.SetMetadata("Source", source); - created.SetMetadata("SourceWithRef", sourceWithRef); - created.SetMetadata("RepositoryCommit", commit); - created.SetMetadata("Skills", string.Join(";", selected)); - created.SetMetadata("SkillCount", selected.Count.ToString()); - created.SetMetadata("Command", commandFor(selected)); - bucket.Add(created); - return created; - }); - - if (missingNames.Count > 0) - addItem(missing, missingNames); - - if (driftedNames.Count > 0) - { - var driftedItem = addItem(drifted, driftedNames); - driftedItem.SetMetadata( - "InstalledRefs", - string.Join(";", driftedNames.Select(name => name + "@" + lockedRef[name])) - ); - } - - if (unknownNames.Count > 0) - { - var unknownItem = addItem(unknown, unknownNames); - unknownItem.SetMetadata("Reason", string.Join("; ", unknownReasons)); - } - - if (satisfiedNames.Count > 0) - addItem(satisfied, satisfiedNames); - } - - // A package that ships skills but carries no pinnable git identity is unknown - // rather than missing: there is no ref to compare against and none to install from. - foreach (var item in unpinnable) - { - foreach (var name in (item.GetMetadata("Skills") ?? string.Empty) - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) - { - claimedNames.Add(name); - } - - var created = new TaskItem(item.GetMetadata("PackageId")); - created.SetMetadata("PackageId", item.GetMetadata("PackageId")); - created.SetMetadata("PackageVersion", item.GetMetadata("PackageVersion")); - created.SetMetadata("Source", item.GetMetadata("Source")); - created.SetMetadata("Skills", item.GetMetadata("Skills")); - created.SetMetadata("SkillCount", item.GetMetadata("SkillCount")); - created.SetMetadata("Reason", item.GetMetadata("Reason")); - created.SetMetadata("Command", string.Empty); - unknown.Add(created); - } - - // ---- Orphans ----------------------------------------------------------------- - foreach (var entry in lockedSource.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) - { - var installedSource = entry.Value; - var slash = installedSource.IndexOf('/'); - if (slash <= 0 || claimedNames.Contains(entry.Key)) - continue; - - // Outside the owners this project references, an entry is the developer's own. - if (!knownOwners.Contains(installedSource.Substring(0, slash)) || knownSources.Contains(installedSource)) - continue; - - var created = new TaskItem(entry.Key); - created.SetMetadata("Skill", entry.Key); - created.SetMetadata("Source", installedSource); - created.SetMetadata("Ref", lockedRef[entry.Key]); - created.SetMetadata("Command", cli + " remove \"" + entry.Key + "\""); - orphaned.Add(created); - } - - Missing = missing.ToArray(); - Drifted = drifted.ToArray(); - Unknown = unknown.ToArray(); - Satisfied = satisfied.ToArray(); - Orphaned = orphaned.ToArray(); - - // No stamp and no fingerprint: this task is reached only by a target a person - // invokes, so reporting every time is the point. The fingerprint existed to stop - // a per-build hook repeating itself once per project; that hook is gone. - ]]> - - - - - @@ -1038,8 +480,7 @@ if (LockFileFound) { - // Same reading as EvaluatePurviewSkillState: walk to the brace that closes the - // 'skills' object, then one regex per flat entry. + // Walk to the brace that closes the 'skills' object, then one regex per flat entry. var namesBySource = new Dictionary>(StringComparer.OrdinalIgnoreCase); var skillsKey = lockText.IndexOf("\"skills\"", StringComparison.Ordinal); var open = skillsKey < 0 ? -1 : lockText.IndexOf('{', skillsKey); @@ -1085,28 +526,13 @@ } } - var packages = SkillSources ?? new ITaskItem[0]; foreach (var pair in namesBySource.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) { - var package = packages.FirstOrDefault(item => - string.Equals(item.GetMetadata("Source"), pair.Key, StringComparison.OrdinalIgnoreCase)); - - ITaskItem created; - if (package != null) - { - created = new TaskItem(package.GetMetadata("SourceWithRef")); - created.SetMetadata("Skills", package.GetMetadata("Skills")); - created.SetMetadata("Origin", package.GetMetadata("PackageId") + " " + package.GetMetadata("PackageVersion")); - } - else - { - // Every skill the source has now, not just the ones recorded: re-adding is - // how an update picks up a skill the source has gained since. - created = new TaskItem(pair.Key); - created.SetMetadata("Skills", "*"); - created.SetMetadata("Origin", "skills-lock.json"); - } - + // Every skill the source has now, not just the ones recorded: re-adding is + // how an update picks up a skill the source has gained since. + var created = new TaskItem(pair.Key); + created.SetMetadata("Skills", "*"); + created.SetMetadata("Origin", "skills-lock.json"); updates.Add(created); } } @@ -1612,6 +1038,12 @@ destination without the prefix is the developer's and is never overwritten. --> + + @@ -1627,6 +1059,7 @@ + @@ -1822,6 +1255,75 @@ bool IsManagedByMarker(string path, string prefix) => FirstLine(path).StartsWith(prefix, StringComparison.Ordinal); + // Every project of a parallel build runs this task, in several processes, so the last + // look at the destination and the write happen under one lock per destination. The lock + // file lives in the temp folder, keyed by the destination's path, so nothing appears in + // the repository. A lock that cannot be had is waited out for a while, then skipped: + // writing without it is how this task behaved before it had one. + FileStream AcquireWriteLock(string destination) + { + var key = Path.GetFullPath(destination); + if (pathComparison == StringComparison.OrdinalIgnoreCase) + key = key.ToUpperInvariant(); + + var lockPath = Path.Combine( + Path.GetTempPath(), + "purview-buildsdk-" + ComputeHash(Encoding.UTF8.GetBytes(key)).ToString("x16", CultureInfo.InvariantCulture) + ".lock" + ); + var stopwatch = Stopwatch.StartNew(); + var timeout = Math.Max(10000, attemptLimit * retryDelay * 4); + while (true) + { + try + { + return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException e) + { + if (stopwatch.ElapsedMilliseconds >= timeout) + { + Log.LogMessage(MessageImportance.Low, "Writing '{0}' without its lock '{1}': {2}", destination, lockPath, e.Message); + return null; + } + Thread.Sleep(25); + } + catch (UnauthorizedAccessException e) + { + Log.LogMessage(MessageImportance.Low, "Writing '{0}' without its lock '{1}': {2}", destination, lockPath, e.Message); + return null; + } + } + } + + // The file KeepWhenNamedFileExists finds named in the destination, when it exists. + string NamedFileThatExists(string destination) + { + if (string.IsNullOrEmpty(KeepWhenNamedFileExists) || !File.Exists(destination)) + return null; + + string text; + try + { + using (var stream = new FileStream(destination, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + { + text = reader.ReadToEnd(); + } + } + catch (Exception e) when (IsTransient(e)) + { + return null; + } + + var match = Regex.Match(text, KeepWhenNamedFileExists); + if (!match.Success) + return null; + + var named = match.Groups["path"].Value; + var directory = Path.GetDirectoryName(Path.GetFullPath(destination)) ?? string.Empty; + return named.Length > 0 && File.Exists(Path.Combine(directory, named)) ? named : null; + } + var destinationFile = DestinationFile ?? string.Empty; if (string.IsNullOrEmpty(destinationFile)) { @@ -1829,7 +1331,8 @@ return Finish(); } - if (File.Exists(destinationFile)) + var destinationExisted = File.Exists(destinationFile); + if (destinationExisted) { // A destination carrying the SDK's own version marker is a file the SDK wrote, // so it is refreshed when the version moves on. A destination without the @@ -1916,39 +1419,71 @@ return Finish(); } - for (var attempt = 1; attempt <= attemptLimit; attempt++) + var writeLock = AcquireWriteLock(destinationFile); + try { - try + // Another project may have written the destination since this one looked: a file that + // appeared is the developer's or the winner's, and one naming a file that exists is kept. + if (!OverwriteExisting && !destinationExisted && File.Exists(destinationFile)) { - WriteAtomically(destinationFile, temporary => File.WriteAllBytes(temporary, content)); - copied++; + skipped++; upToDate = true; - break; + return Finish(); } - catch (Exception e) when (IsTransient(e)) + + var keptFor = NamedFileThatExists(destinationFile); + if (keptFor != null) { - if (attempt < attemptLimit) + Log.LogMessage( + MessageImportance.Normal, + "'{0}' was kept: since this project read it, it was rewritten for '{1}'.", + destinationFile, + keptFor + ); + skipped++; + upToDate = true; + return Finish(); + } + + for (var attempt = 1; attempt <= attemptLimit; attempt++) + { + try { - Log.LogMessage( - MessageImportance.Low, - "Retrying bootstrap write of '{0}' (attempt {1} of {2}): {3}", - destinationFile, - attempt + 1, - attemptLimit, - e.Message - ); - Thread.Sleep(retryDelay + ((attempt - 1) * 100)); + WriteAtomically(destinationFile, temporary => File.WriteAllBytes(temporary, content)); + copied++; + upToDate = true; + break; + } + catch (Exception e) when (IsTransient(e)) + { + if (attempt < attemptLimit) + { + Log.LogMessage( + MessageImportance.Low, + "Retrying bootstrap write of '{0}' (attempt {1} of {2}): {3}", + destinationFile, + attempt + 1, + attemptLimit, + e.Message + ); + Thread.Sleep(retryDelay + ((attempt - 1) * 100)); + } + else + { + ReportFailure(sourceDescription, destinationFile, attempt, e.Message); + } } - else + catch (Exception e) when (IsPermanent(e)) { ReportFailure(sourceDescription, destinationFile, attempt, e.Message); + break; } } - catch (Exception e) when (IsPermanent(e)) - { - ReportFailure(sourceDescription, destinationFile, attempt, e.Message); - break; - } + } + finally + { + if (writeLock != null) + writeLock.Dispose(); } Log.LogMessage( @@ -2446,9 +1981,9 @@ package runner. Each runner spells "run this npm package" its own way, so the SDK maps a runner name to a launcher rather than assuming npx: - npx npx -y skills@1 (ships with Node; the default) + bun bun x skills@1 (the default) + npx npx -y skills@1 (ships with Node) bunx bunx skills@1 - bun bun x skills@1 pnpm pnpm dlx skills@1 yarn yarn dlx skills@1 (Yarn 2+; Yarn 1 has no 'dlx') deno deno run -A npm:skills@1 @@ -2457,39 +1992,30 @@ A runner this SDK does not know is still usable: it falls through to ' ', which is the form most new runners adopt. - Two launchers are produced on purpose. The executed one pins the CLI - to a major so a breaking release cannot change what the install target - does; the displayed one drops the pin, because a recommendation is a - command for a person to run and 'npx skills add' is the form they know. + The launcher pins the CLI to a major (PurviewSkillsPackage, default + skills@1), so a breaking release cannot change what a target does. ===================================================================== --> skills - skills npx -y $(PurviewSkillsPackage) - npx skills bunx $(PurviewSkillsPackage) - bunx skills bun x $(PurviewSkillsPackage) - bun x skills pnpm dlx $(PurviewSkillsPackage) - pnpm dlx skills yarn dlx $(PurviewSkillsPackage) - yarn dlx skills deno run -A npm:$(PurviewSkillsPackage) - deno run -A npm:skills @@ -2575,12 +2101,12 @@ - <_PurviewSkillsProjectInfoReply Include="$(MSBuildProjectFullPath)"> - $(ProjectAssetsFile) - $(NuGetPackageFolders) - + <_PurviewSkillsProjectInfoReply Include="$(MSBuildProjectFullPath)" /> @@ -2662,115 +2183,12 @@ - - - <_PurviewSkillsUnrestoredProject - Include="@(_PurviewSkillsProjectInfo)" - Condition="'%(_PurviewSkillsProjectInfo.ProjectAssetsFile)' == '' OR !Exists('%(_PurviewSkillsProjectInfo.ProjectAssetsFile)')" - /> - <_PurviewSkillsRestoredProject - Include="@(_PurviewSkillsProjectInfo)" - Exclude="@(_PurviewSkillsUnrestoredProject)" - /> - - - - - - - - - - - - - - <_PurviewSkillsLeaderRemoveProperties>TargetFramework;RuntimeIdentifier - - - - - - - - - - - - - - - - - - - - - - - $(PurviewSkillsSource) - * - (explicit source) - - + + + + - <_PurviewSkillsCommandRequired Condition="'@(PurviewSkillSource)' != ''" - >true + $([MSBuild]::NormalizePath('$(_PurviewSkillsRepositoryRoot)', 'skills-lock.json')) + <_PurviewSkillsCommandRequired>true + + - - false + <_PurviewSkillsInstallCommand Condition="'$(PurviewSkillsSource)' != ''" + >$(PurviewSkillsCommand) add "$(PurviewSkillsSource)" --skill "*"$(_PurviewSkillsAgentArguments) -y + <_PurviewSkillsInstallCommand Condition="'$(PurviewSkillsSource)' == ''" + >$(PurviewSkillsCommand) experimental_install - - - - - - - - - - + $([MSBuild]::NormalizePath('$(_PurviewSkillsRepositoryRoot)', 'skills-lock.json')) - + @@ -3025,7 +2431,7 @@ - - - - - <_PurviewSkillRecommendationRoot Condition="'$(_PurviewSkillRecommendationRoot)' == ''" - >$(_PurviewSkillsRepositoryRoot) - - $([MSBuild]::NormalizePath('$(_PurviewSkillRecommendationRoot)', 'skills-lock.json')) - - <_PurviewSkillsCommandDisplay>@(PurviewSkillsRunnerLauncher->WithMetadataValue('Identity', '$(PurviewSkillsRunner)')->'%(Display)') - <_PurviewSkillsCommandDisplay Condition="'$(_PurviewSkillsCommandDisplay)' == ''" - >$(PurviewSkillsRunner) skills - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + <_PurviewLinkedSdkFile Include="@(None)" Condition="$([System.String]::Copy('%(None.Link)').StartsWith('Sdk\')) OR $([System.String]::Copy('%(None.Link)').StartsWith('Sdk/'))" > - $([System.String]::Copy('%(None.Link)').Substring(4).Replace('\', '/')) + <_PurviewSdkRelativePath>$([System.String]::Copy('%(None.Link)').Substring(4).Replace('\', '/')) + + <_PurviewLinkedSdkFile> + <_PurviewSdkRelativeExtension>$([System.IO.Path]::GetExtension('%(_PurviewSdkRelativePath)').ToLowerInvariant()) + Sdk/%(_PurviewSdkRelativePath) + + <_PurviewLinkedSdkFile + Condition="$([System.String]::Copy('%(_PurviewSdkRelativePath)').StartsWith('.github/')) + OR $([System.String]::Copy('%(_PurviewSdkRelativePath)').StartsWith('build/')) + OR $([System.String]::Copy('%(_PurviewSdkRelativePath)').StartsWith('buildTransitive/')) + OR $([System.String]::Copy('%(_PurviewSdkRelativePath)').StartsWith('buildMultiTargeting/')) + OR (!$([System.String]::Copy('%(_PurviewSdkRelativePath)').Contains('/')) + AND ('%(_PurviewSdkRelativeExtension)' == '.md' OR '%(_PurviewSdkRelativeExtension)' == '.png' OR '%(_PurviewSdkRelativeExtension)' == '.jpg' OR '%(_PurviewSdkRelativeExtension)' == '.jpeg' OR '%(_PurviewSdkRelativeExtension)' == '.gif' OR '%(_PurviewSdkRelativeExtension)' == '.svg'))" + > + %(_PurviewSdkRelativePath) @@ -3444,7 +2660,7 @@ + + + <_PurviewMultiThreadedBuildIsExplicit Condition="'$(PurviewMultiThreadedBuild)' != ''" + >true + true + + + + + + + + + + + + + + + + + + + + 0 ? Retries : 3; + var retryDelay = RetryDelayMilliseconds > 0 ? RetryDelayMilliseconds : 500; + + Exists = System.IO.File.Exists(File); + Readable = !Exists; + Text = string.Empty; + + for (var attempt = 1; Exists && !Readable && attempt <= attemptLimit; attempt++) + { + try + { + using (var stream = new FileStream(File, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + { + Text = reader.ReadToEnd(); + } + Readable = true; + } + catch (FileNotFoundException) + { + // Replaced between the existence check and the open. + Exists = System.IO.File.Exists(File); + Readable = !Exists; + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + if (attempt < attemptLimit) + { + Thread.Sleep(retryDelay); + } + else + { + Log.LogMessage( + MessageImportance.Normal, + "'{0}' could not be read after {1} attempt(s) and was left alone: {2}", + File, + attempt, + e.Message + ); + } + } + } + ]]> + + + + <_DirectoryBuildPropsDirectory>$([System.IO.Path]::GetDirectoryName('$(DirectoryBuildPropsPath)')) @@ -4009,17 +3328,74 @@ <_EffectiveRepositoryDirectoryBuildRspFilePath Condition="'$(_EffectiveRepositoryDirectoryBuildRspFilePath)' == '' AND '$(_DirectoryBuildPropsDirectory)' != ''" >$([System.IO.Path]::Combine('$(_DirectoryBuildPropsDirectory)', 'Directory.Build.rsp')) - <_PurviewRspEnabled Condition="'$(PurviewMultiThreadedBuild)' != 'false'">true - <_PurviewRspEnabled Condition="'$(_PurviewRspEnabled)' == ''">false - - $(_EffectiveRepositoryDirectoryBuildRspFilePath) + <_PurviewRspDestinationExists>false + <_PurviewRspDestinationReadable>true + <_PurviewRspDestinationText> + + + + + + + + + + + + <_PurviewRspDestinationFirstLine Condition="'$(_PurviewRspDestinationExists)' == 'true'" + >$([System.Text.RegularExpressions.Regex]::Match($(_PurviewRspDestinationText), `^[^\r\n]*`).Value) + <_PurviewRspIsManaged + Condition="'$(_PurviewRspDestinationExists)' == 'true' AND $(_PurviewRspDestinationFirstLine.StartsWith('$(PurviewRepositoryRspVersionMarker)'))" + >true + + <_PurviewRspDirectory Condition="'$(_EffectiveRepositoryDirectoryBuildRspFilePath)' != ''" + >$([System.IO.Path]::GetDirectoryName('$(_EffectiveRepositoryDirectoryBuildRspFilePath)')) + <_PurviewRspNamedClient Condition="'$(_PurviewRspIsManaged)' == 'true'" + >$([System.Text.RegularExpressions.Regex]::Match($(_PurviewRspDestinationText), `(?m)^# -mt:false: the Blazor WebAssembly client \x27(?<path>[^\x27\r\n]+)\x27`).Groups['path'].Value) + <_PurviewRspNamedClientPath Condition="'$(_PurviewRspNamedClient)' != ''" + >$([System.IO.Path]::Combine('$(_PurviewRspDirectory)', '$(_PurviewRspNamedClient)')) + <_PurviewRspNamedClientExists + Condition="'$(_PurviewRspNamedClientPath)' != '' AND Exists('$(_PurviewRspNamedClientPath)')" + >true + <_PurviewRspEnabled + Condition="'$(_PurviewMultiThreadedBuildIsExplicit)' == 'true' AND '$(PurviewMultiThreadedBuild)' != 'false'" + >true + <_PurviewRspEnabled + Condition="'$(_PurviewMultiThreadedBuildIsExplicit)' != 'true' AND '$(IsBlazorWebAssemblyProject)' != 'true' AND '$(_PurviewRspNamedClientExists)' != 'true'" + >true + <_PurviewRspEnabled Condition="'$(_PurviewRspEnabled)' == ''">false + + <_PurviewRspClient + Condition="'$(_PurviewRspEnabled)' != 'true' AND '$(IsBlazorWebAssemblyProject)' == 'true'" + >$([MSBuild]::MakeRelative('$(_PurviewRspDirectory)$([System.IO.Path]::DirectorySeparatorChar)', '$(MSBuildProjectFullPath)').Replace('\', '/')) + <_PurviewRspClient + Condition="'$(_PurviewRspClient)' == '' AND '$(_PurviewRspEnabled)' != 'true' AND '$(_PurviewRspNamedClientExists)' == 'true'" + >$(_PurviewRspNamedClient) <_RepositoryDirectoryBuildRspLines Include="$(PurviewRepositoryRspVersionMarker)" /> <_RepositoryDirectoryBuildRspLines Include="# Managed by Purview.BuildSdk. Delete the marker line to take ownership of this file." /> + <_RepositoryDirectoryBuildRspLines + Condition="'$(_PurviewRspClient)' != ''" + Include="$([MSBuild]::Escape(`# -mt:false: the Blazor WebAssembly client '$(_PurviewRspClient)' cannot build multithreaded (PRBLZ0002).`))" + /> <_RepositoryDirectoryBuildRspLines Condition="'$(_PurviewRspEnabled)' == 'true'" Include="-mt" /> <_PurviewRspContentMatches - Condition="'$(_PurviewRspEnabled)' != 'true' AND '$(_PurviewRspHasDisabledSwitch)' == 'true'" + Condition="'$(_PurviewRspEnabled)' != 'true' AND '$(_PurviewRspHasDisabledSwitch)' == 'true' AND ('$(IsBlazorWebAssemblyProject)' != 'true' OR '$(_PurviewRspNamedClientExists)' == 'true')" >true - + <_PurviewRspNeedsSync - Condition="'$(_PurviewRspDestinationExists)' != 'true' AND '$(_PurviewRspEnabled)' == 'true'" + Condition="'$(_PurviewRspDestinationExists)' != 'true' AND ('$(_PurviewRspEnabled)' == 'true' OR '$(_PurviewRspClient)' != '')" >true <_PurviewRspNeedsSync Condition="'$(_PurviewRspDestinationExists)' == 'true' AND '$(_PurviewRspIsManaged)' == 'true' AND '$(_PurviewRspContentMatches)' != 'true'" @@ -4068,9 +3438,15 @@ Condition="'$(PurviewRepoBootstrapMode)' == 'Always' OR '$(PurviewRepoBootstrapMode)' == 'WarnOnDrift'" >true <_PurviewRspOverwrite - Condition="'$(_PurviewRepoBootstrapOverwriteExisting)' == 'true' OR '$(_PurviewRspIsManaged)' == 'true'" + Condition="'$(_PurviewRepoBootstrapOverwriteExisting)' == 'true' OR '$(_PurviewRspIsManaged)' == 'true' OR ('$(_PurviewRspDestinationExists)' != 'true' AND '$(_PurviewRspClient)' != '' AND '$(IsBlazorWebAssemblyProject)' == 'true')" >true <_PurviewRspOverwrite Condition="'$(_PurviewRspOverwrite)' == ''">false + + <_PurviewRspKeepWhenNamedFileExists + Condition="'$(_PurviewMultiThreadedBuildIsExplicit)' != 'true' AND '$(IsBlazorWebAssemblyProject)' != 'true'" + >(?m)^# -mt:false: the Blazor WebAssembly client \x27(?<path>[^\x27\r\n]+)\x27 @@ -4191,6 +3568,9 @@ Declaring these properties unconditionally is therefore a requirement, not a style preference, and it is reported here rather than left to be discovered downstream. + + TargetFramework and TargetFrameworks are not checked: a conditioned declaration of either is + honoured by Targets/TargetFrameworkOwnership.targets, which runs after the project body. Opt out with DisableUnconditionalProjectDeclarationCheck=true. ===================================================================== --> - <_PurviewConditionedDeclaration>$([System.Text.RegularExpressions.Regex]::Match($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<(?<decl>TargetFrameworks?|IsRoslynComponent|IsRoslynComponentOnly|IsPackable|PurviewTargetFrameworkSet|RootNamespace)\s+[A-Za-z][\w:.-]*\s*=`).Groups['decl'].Value) + <_PurviewConditionedDeclaration>$([System.Text.RegularExpressions.Regex]::Match($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<(?<decl>IsRoslynComponent|IsRoslynComponentOnly|IsPackable|PurviewTargetFrameworkSet|RootNamespace)\s+[A-Za-z][\w:.-]*\s*=`).Groups['decl'].Value) + + + + + + + + + + + + + + + + + + <_PurviewWebAssemblyRspDirectory>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'Directory.Build.rsp')) + <_PurviewWebAssemblyRspPath Condition="'$(_PurviewWebAssemblyRspDirectory)' != ''" + >$([System.IO.Path]::Combine('$(_PurviewWebAssemblyRspDirectory)', 'Directory.Build.rsp')) + <_PurviewWebAssemblyRspText Condition="'$(_PurviewWebAssemblyRspPath)' != ''" + >$([System.IO.File]::ReadAllText('$(_PurviewWebAssemblyRspPath)')) + <_PurviewWebAssemblyRspEnablesMt + Condition="'$(_PurviewWebAssemblyRspText)' != '' AND $([System.Text.RegularExpressions.Regex]::IsMatch($(_PurviewWebAssemblyRspText), `(?im)(^|\s)[-/](mt|multithreaded)(:true)?(?=\s|$)`))" + >true + + <_PurviewWebAssemblyRspWasRewritten + Condition="'$(_PurviewWebAssemblyRspEnablesMt)' != 'true' AND '$(_PurviewRspIsManaged)' == 'true' AND '$(_PurviewRspHasEnabledSwitch)' == 'true'" + >true + <_PurviewWebAssemblyMtSource Condition="'$(_PurviewWebAssemblyRspWasRewritten)' == 'true'" + >-mt came from '$(_PurviewWebAssemblyRspPath)', the Directory.Build.rsp Purview.BuildSdk manages, which has now been rewritten to -mt:false because this repository has a WebAssembly client. Run the build again. + <_PurviewWebAssemblyMtSource + Condition="'$(_PurviewWebAssemblyMtSource)' == '' AND '$(_PurviewWebAssemblyRspEnablesMt)' == 'true'" + >-mt came from '$(_PurviewWebAssemblyRspPath)'. Remove -mt from that Directory.Build.rsp, or replace it with -mt:false. + <_PurviewWebAssemblyMtSource Condition="'$(_PurviewWebAssemblyMtSource)' == ''" + >-mt came from the command line, not from a Directory.Build.rsp. Build without it, or pass -mt:false. + <_PurviewWebAssemblyMtSource + Condition="'$(_PurviewWebAssemblyRspWasRewritten)' != 'true' AND '$(_PurviewMultiThreadedBuildIsExplicit)' == 'true' AND '$(PurviewMultiThreadedBuild)' != 'false'" + >$(_PurviewWebAssemblyMtSource) PurviewMultiThreadedBuild is set to '$(PurviewMultiThreadedBuild)', which keeps -mt in the managed Directory.Build.rsp. + <_PurviewWebAssemblyMtFix Condition="'$(_PurviewWebAssemblyRspWasRewritten)' != 'true'" + > To keep the repository off multithreaded builds, set <PurviewMultiThreadedBuild>false</PurviewMultiThreadedBuild> in Directory.Build.props, which writes -mt:false to the managed Directory.Build.rsp. + + + + + + + + + + + + + ( + (IgnoredProperties ?? "").Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()), + StringComparer.Ordinal); + + var msbuildFiles = System.IO.Directory.EnumerateFiles(Directory, "*.*", SearchOption.AllDirectories) + .Where(f => f.EndsWith(".props", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".targets", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + var sidecars = System.IO.Directory.EnumerateFiles(Directory, "*" + SidecarSuffix, SearchOption.AllDirectories) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + + // Everything a sidecar entry may legitimately describe: what the files define, read or export. + var known = new HashSet(StringComparer.Ordinal); + var exported = new List>(); + var reference = new Regex(@"\$\((?[A-Za-z_][A-Za-z0-9_]*)"); + + foreach (var file in msbuildFiles) + { + XDocument document; + try + { + document = XDocument.Load(file, LoadOptions.SetLineInfo); + } + catch (XmlException) + { + continue; + } + + foreach (var element in document.Descendants()) + { + var parent = element.Parent == null ? "" : element.Parent.Name.LocalName; + if (parent == "PropertyGroup") + known.Add(element.Name.LocalName); + + if (parent == "ItemGroup" && element.Name.LocalName == "CompilerVisibleProperty") + { + var include = (string)element.Attribute("Include") ?? ""; + foreach (var name in include.Split(';').Select(n => n.Trim()).Where(n => n.Length > 0 && !n.StartsWith("@(", StringComparison.Ordinal) && !n.StartsWith("$(", StringComparison.Ordinal))) + { + known.Add(name); + exported.Add(Tuple.Create(name, file, ((IXmlLineInfo)element).LineNumber)); + } + } + + foreach (var text in element.Attributes().Select(a => a.Value).Concat(element.Nodes().OfType().Select(t => t.Value))) + { + foreach (Match match in reference.Matches(text)) + known.Add(match.Groups["name"].Value); + } + } + } + + var described = new Dictionary(StringComparer.Ordinal); + foreach (var sidecar in sidecars) + { + var describedFile = sidecar.Substring(0, sidecar.Length - SidecarSuffix.Length); + if (!File.Exists(describedFile)) + { + Log.LogError(null, "PRPSC0004", null, sidecar, 0, 0, 0, 0, + "'{0}' describes '{1}', which does not exist. MSBuild Editor loads a sidecar only from '{2}' beside the file it imports, so this one is never read. Rename it after the .props/.targets file it describes.", + Path.GetFileName(sidecar), Path.GetFileName(describedFile), SidecarSuffix); + } + + List names; + string error; + if (!TryReadPropertyNames(File.ReadAllText(sidecar), out names, out error)) + { + Log.LogError(null, "PRPSC0003", null, sidecar, 0, 0, 0, 0, + "'{0}' cannot be read as a property schema: {1}.", Path.GetFileName(sidecar), error); + continue; + } + + foreach (var name in names) + { + if (!described.ContainsKey(name)) + described[name] = sidecar; + } + } + + foreach (var export in exported) + { + if (described.ContainsKey(export.Item1) || ignored.Contains(export.Item1)) + continue; + + Log.LogError(null, "PRPSC0001", null, export.Item2, export.Item3, 0, 0, 0, + "'{0}' is exported as a CompilerVisibleProperty but no property schema describes it, so an analyzer author cannot find out what build_property.{0} means. Add it under \"properties\" in '{1}{2}' (beside this file), or list it in PurviewPropertySchemaIgnoredProperties if the package does not own it.", + export.Item1, Path.GetFileName(export.Item2), SidecarSuffix); + } + + foreach (var entry in described.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + if (known.Contains(entry.Key)) + continue; + + Log.LogError(null, "PRPSC0002", null, entry.Value, 0, 0, 0, 0, + "'{0}' describes the property '{1}', but no .props/.targets file under '{2}' defines, reads or exports it. Remove the entry, or define the property with its default.", + Path.GetFileName(entry.Value), entry.Key, Directory); + } + + return !Log.HasLoggedErrors; + } + + // A deliberately small JSON reader: only the member names of the top-level "properties" + // object are needed, and a task cannot take a package dependency. + static bool TryReadPropertyNames(string json, out List names, out string error) + { + names = new List(); + error = null; + var position = 0; + try + { + SkipWhitespace(json, ref position); + if (position >= json.Length || json[position] != '{') + throw new FormatException("the document is not a JSON object"); + + var found = false; + foreach (var member in ReadObjectMembers(json, ref position)) + { + if (member.Key != "properties") + continue; + + if (member.Value == null) + throw new FormatException("\"properties\" is not an object"); + + names.AddRange(member.Value); + found = true; + } + + SkipWhitespace(json, ref position); + if (position != json.Length) + throw new FormatException("unexpected content after the root object"); + + if (!found) + throw new FormatException("it has no \"properties\" object"); + + return true; + } + catch (FormatException ex) + { + error = ex.Message; + return false; + } + } + + // Yields each member of the object at 'position': its name, and - when its value is an + // object - that object's member names. Nested values are read and discarded. + static List>> ReadObjectMembers(string json, ref int position) + { + var members = new List>>(); + Expect(json, ref position, '{'); + SkipWhitespace(json, ref position); + if (Peek(json, position) == '}') + { + position++; + return members; + } + + while (true) + { + SkipWhitespace(json, ref position); + var name = ReadString(json, ref position); + SkipWhitespace(json, ref position); + Expect(json, ref position, ':'); + SkipWhitespace(json, ref position); + + List childNames = null; + if (Peek(json, position) == '{') + childNames = ReadObjectMembers(json, ref position).Select(m => m.Key).ToList(); + else + SkipValue(json, ref position); + + members.Add(new KeyValuePair>(name, childNames)); + SkipWhitespace(json, ref position); + var next = Peek(json, position); + position++; + if (next == '}') + return members; + if (next != ',') + throw new FormatException("expected ',' or '}' at offset " + (position - 1)); + } + } + + static void SkipValue(string json, ref int position) + { + var c = Peek(json, position); + if (c == '{') + { + ReadObjectMembers(json, ref position); + } + else if (c == '[') + { + position++; + SkipWhitespace(json, ref position); + if (Peek(json, position) == ']') + { + position++; + return; + } + + while (true) + { + SkipWhitespace(json, ref position); + SkipValue(json, ref position); + SkipWhitespace(json, ref position); + var next = Peek(json, position); + position++; + if (next == ']') + return; + if (next != ',') + throw new FormatException("expected ',' or ']' at offset " + (position - 1)); + } + } + else if (c == '"') + { + ReadString(json, ref position); + } + else + { + var start = position; + while (position < json.Length && "-+.0123456789eEtruefalsn".IndexOf(json[position]) >= 0) + position++; + var literal = json.Substring(start, position - start); + double number; + if (literal != "true" && literal != "false" && literal != "null" + && !double.TryParse(literal, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out number)) + throw new FormatException("unexpected value at offset " + start); + } + } + + static string ReadString(string json, ref int position) + { + Expect(json, ref position, '"'); + var builder = new System.Text.StringBuilder(); + while (true) + { + if (position >= json.Length) + throw new FormatException("unterminated string"); + + var c = json[position++]; + if (c == '"') + return builder.ToString(); + + if (c != '\\') + { + builder.Append(c); + continue; + } + + if (position >= json.Length) + throw new FormatException("unterminated escape"); + + var escaped = json[position++]; + if (escaped == 'u') + { + if (position + 4 > json.Length) + throw new FormatException("truncated \\u escape"); + builder.Append((char)Convert.ToInt32(json.Substring(position, 4), 16)); + position += 4; + } + else + { + builder.Append(escaped == 'n' ? '\n' : escaped == 't' ? '\t' : escaped == 'r' ? '\r' : escaped == 'b' ? '\b' : escaped == 'f' ? '\f' : escaped); + } + } + } + + static void Expect(string json, ref int position, char expected) + { + if (Peek(json, position) != expected) + throw new FormatException("expected '" + expected + "' at offset " + position); + position++; + } + + static char Peek(string json, int position) => position < json.Length ? json[position] : '\0'; + + static void SkipWhitespace(string json, ref int position) + { + while (position < json.Length && char.IsWhiteSpace(json[position])) + position++; + } + } + ]]> + + + + + + + <_PurviewPropertySchemaDirectory>$(PurviewPropertySchemaDirectory) + <_PurviewPropertySchemaDirectory Condition="'$(_PurviewPropertySchemaDirectory)' == ''" + >$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', 'Sdk')) + + + + + + + diff --git a/src/src/BuildSdk/Sdk/Targets/TargetFrameworkOwnership.targets b/src/src/BuildSdk/Sdk/Targets/TargetFrameworkOwnership.targets new file mode 100644 index 0000000..48eed15 --- /dev/null +++ b/src/src/BuildSdk/Sdk/Targets/TargetFrameworkOwnership.targets @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs index 83c2bbe..27875f2 100644 --- a/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs +++ b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs @@ -27,11 +27,11 @@ public static string Library(string properties = "", string items = "") => """; - public static string Client(string properties = "", string items = "") => + public static string Client(string properties = "", string items = "", string targetFramework = "net10.0") => $""" - net10.0 + {targetFramework} true {properties} @@ -105,11 +105,16 @@ public static Task AddSiblingAsync( /// /// Replaces the harness's central package versions with the ones a Blazor Web App restores: the /// WebAssembly packages for the TFM the fixtures target, and the automatic Purview telemetry packages. + /// Pass for a client on another TFM. /// - public static Task WritePackageVersionsAsync(ProjectHarness owner, CancellationToken cancellationToken) => + public static Task WritePackageVersionsAsync( + ProjectHarness owner, + CancellationToken cancellationToken, + string webAssemblyVersion = "10.0.*" + ) => File.WriteAllTextAsync( Path.Combine(owner.SolutionDirectory, "Directory.Packages.props"), - """ + $$""" true @@ -118,8 +123,8 @@ public static Task WritePackageVersionsAsync(ProjectHarness owner, CancellationT - - + + """, diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs index a28f1e7..8717c13 100644 --- a/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs @@ -77,9 +77,10 @@ public async Task AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime(Canc // Compiling is not evidence that they work in the browser, so this publishes a client that exercises // both - generated activity, log and metric code, and a Telemetry.Abstractions type - and runs it on // the published WebAssembly runtime, booted under Node. + // The probe is runtime evidence, not style: its IDE suggestions must not fail the publish. using var client = await BlazorFixtures.CreateAsync( "Shop.Client", - BlazorFixtures.Client(), + BlazorFixtures.Client("false"), cancellationToken: cancellationToken ); await BlazorFixtures.WritePackageVersionsAsync(client, cancellationToken); diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorWebAssemblyMultiThreadedBuildTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorWebAssemblyMultiThreadedBuildTests.cs new file mode 100644 index 0000000..f701f4e --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorWebAssemblyMultiThreadedBuildTests.cs @@ -0,0 +1,357 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins PRBLZ0002 and the WebAssembly rule of the managed Directory.Build.rsp. A net11.0 +/// WebAssembly client cannot build under MSBuild's multithreaded mode: the WebAssembly SDK's +/// ReadWasmProps task comes from a custom task factory that cannot run in the out-of-process task +/// host -mt moves it to, and fails with MSB4060, an error that names nothing a consumer can +/// trace back to this SDK. So a client writes the response file's -mt:false form, and a client +/// built multithreaded anyway stops with PRBLZ0002 before the WebAssembly SDK's targets run. +/// +/// Every other harness test opts out of the response file, which is why the Blazor tests never saw +/// this; these opt back in. They target net11.0 because a net10.0 client builds under +/// -mt: the failure is in the 11.0 WebAssembly SDK. +/// +sealed class BlazorWebAssemblyMultiThreadedBuildTests +{ + const string ClientName = "Shop.Client"; + + // The WebAssembly SDK's own error. PRBLZ0002 names ReadWasmProps too, so match the task factory. + const string WebAssemblyFailure = "JsonToItemsTaskFactory"; + const string ManagedMultiThreadedRsp = "# Purview.BuildSdk - managed file\n-mt\n-nr:false\n"; + + [Test] + public async Task ForcedMultiThreadedBuild_StopsWithPRBLZ0002_BeforeTheWebAssemblySdk( + CancellationToken cancellationToken + ) + { + using var client = await CreateClientAsync(preImportProperties: null, cancellationToken); + + var (code, output, errors) = await client.RunMSBuildAsync("-restore -t:Build -mt -nr:false", cancellationToken); + + await Assert.That(code).IsNotZero().Because(output + errors); + await Assert.That(output + errors).Contains("error PRBLZ0002"); + await Assert.That(output + errors).Contains("command line"); + await Assert.That(output + errors).Contains("false"); + await Assert.That(output + errors).Contains("-mt:false"); + await Assert.That(output + errors).DoesNotContain(WebAssemblyFailure); + } + + /// + /// The check is scoped to the failure it replaces: a net10.0 client builds under -mt, so + /// stopping it would break a build that works. + /// + [Test] + public async Task ForcedMultiThreadedBuild_OfANet10Client_StillBuilds(CancellationToken cancellationToken) + { + using var client = await CreateClientAsync( + preImportProperties: null, + cancellationToken, + targetFramework: "net10.0", + webAssemblyVersion: "10.0.*" + ); + + var (code, output, errors) = await client.RunMSBuildAsync("-restore -t:Build -mt -nr:false", cancellationToken); + + await Assert.That(code).IsZero().Because(output + errors); + await Assert.That(output + errors).DoesNotContain("PRBLZ0002"); + } + + [Test] + public async Task MultiThreadedBuildOptOut_Builds(CancellationToken cancellationToken) + { + using var client = await CreateClientAsync( + "truefalse", + cancellationToken + ); + + var (success, output, errors) = await client.BuildAsync(restore: true, cancellationToken: cancellationToken); + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + + // The second build is the one that reads the response file the first wrote. + var (secondSuccess, secondOutput, secondErrors) = await client.BuildAsync( + restore: false, + cancellationToken: cancellationToken + ); + await Assert.That(secondSuccess).IsTrue().Because(TestHelpers.GenerateError(secondOutput, secondErrors)); + await Assert.That(await ReadRspLinesAsync(client, cancellationToken)).Contains("-mt:false"); + } + + /// + /// A fresh repository with a WebAssembly client builds with no extra settings: the client's + /// bootstrap writes the non-multithreaded form, naming itself so the repository's other projects + /// leave it alone. + /// + [Test] + public async Task Bootstrap_FromAWebAssemblyClient_WritesTheNonMultithreadedForm( + CancellationToken cancellationToken + ) + { + using var client = await CreateClientAsync(BootstrapOn, cancellationToken); + + var (success, output, errors) = await client.BuildAsync(restore: true, cancellationToken: cancellationToken); + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + + var lines = await ReadRspLinesAsync(client, cancellationToken); + await Assert.That(lines[0]).StartsWith("# Purview.BuildSdk - managed file"); + await Assert.That(lines).Contains("-mt:false"); + await Assert.That(lines).DoesNotContain("-mt"); + await Assert.That(lines).Contains(ClientReasonLine(client)); + + var (secondSuccess, secondOutput, secondErrors) = await client.BuildAsync( + restore: false, + cancellationToken: cancellationToken + ); + await Assert.That(secondSuccess).IsTrue().Because(TestHelpers.GenerateError(secondOutput, secondErrors)); + await Assert.That(secondOutput + secondErrors).DoesNotContain(WebAssemblyFailure); + } + + /// + /// A repository upgrading from 2.0.x already has a managed -mt file, and the build that finds it + /// is already multithreaded. It cannot be rescued, so it is rewritten and stopped with an error that + /// says so; the next build is not multithreaded. + /// + [Test] + public async Task Bootstrap_ManagedMultithreadedFile_IsRewrittenAndTheBuildStopsClearly( + CancellationToken cancellationToken + ) + { + using var client = await CreateClientAsync(BootstrapOn, cancellationToken); + var (restoreCode, restoreOutput, restoreErrors) = await client.RunMSBuildAsync("-t:Restore", cancellationToken); + await Assert.That(restoreCode).IsZero().Because(restoreOutput + restoreErrors); + + await File.WriteAllTextAsync(RspPath(client), ManagedMultiThreadedRsp, cancellationToken); + + var (success, output, errors) = await client.BuildAsync(restore: false, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse(); + await Assert.That(output + errors).Contains("error PRBLZ0002"); + await Assert.That(output + errors).Contains("rewritten to -mt:false"); + await Assert.That(output + errors).DoesNotContain(WebAssemblyFailure); + + var lines = await ReadRspLinesAsync(client, cancellationToken); + await Assert.That(lines).Contains("-mt:false"); + await Assert.That(lines).DoesNotContain("-mt"); + + var (secondSuccess, secondOutput, secondErrors) = await client.BuildAsync( + restore: false, + cancellationToken: cancellationToken + ); + await Assert.That(secondSuccess).IsTrue().Because(TestHelpers.GenerateError(secondOutput, secondErrors)); + } + + /// + /// A response file without the marker line is the developer's. It is never rewritten, even when it + /// is what makes the client's build fail, and the error points at it. + /// + [Test] + public async Task UserOwnedResponseFile_IsNeverRewritten(CancellationToken cancellationToken) + { + using var client = await CreateClientAsync(BootstrapOn, cancellationToken); + var (restoreCode, restoreOutput, restoreErrors) = await client.RunMSBuildAsync("-t:Restore", cancellationToken); + await Assert.That(restoreCode).IsZero().Because(restoreOutput + restoreErrors); + + const string owned = "# my own switches\n-mt\n-nr:false\n"; + await File.WriteAllTextAsync(RspPath(client), owned, cancellationToken); + + var (success, output, errors) = await client.BuildAsync(restore: false, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse(); + await Assert.That(output + errors).Contains("error PRBLZ0002"); + await Assert.That(output + errors).Contains("Directory.Build.rsp"); + await Assert.That(output + errors).DoesNotContain(WebAssemblyFailure); + await Assert.That(await File.ReadAllTextAsync(RspPath(client), cancellationToken)).IsEqualTo(owned); + } + + /// An explicit setting is the user's decision and wins over the WebAssembly rule. + [Test] + public async Task ExplicitMultiThreadedBuild_OnAWebAssemblyClient_WritesMt(CancellationToken cancellationToken) + { + using var client = await CreateClientAsync( + BootstrapOn + "true", + cancellationToken + ); + + // This build is not multithreaded yet - the host read its switches before the file existed. + var (success, output, errors) = await client.BuildAsync(restore: true, cancellationToken: cancellationToken); + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + + var lines = await ReadRspLinesAsync(client, cancellationToken); + await Assert.That(lines).Contains("-mt"); + await Assert.That(lines).DoesNotContain("-mt:false"); + } + + /// + /// The response file is per repository, so a library built on its own must not turn the mode back + /// on while the client it names is still there. + /// + [Test] + public async Task Bootstrap_FromALibrary_KeepsAFileWrittenForAnExistingClient(CancellationToken cancellationToken) + { + using var library = await CreateLibraryAsync(cancellationToken); + var clientPath = Path.Combine(library.SolutionDirectory, ClientName, ClientName + ".csproj"); + Directory.CreateDirectory(Path.GetDirectoryName(clientPath)!); + await File.WriteAllTextAsync(clientPath, "", cancellationToken); + + var written = ClientRsp($"{ClientName}/{ClientName}.csproj"); + await File.WriteAllTextAsync(RspPath(library), written, cancellationToken); + + var (success, output, errors) = await library.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(await File.ReadAllTextAsync(RspPath(library), cancellationToken)).IsEqualTo(written); + } + + /// + /// In a parallel build a library reads the response file, then writes it later. A client can write + /// its -mt:false form in between, and the library must not undo it: the write checks the file + /// again before replacing it. The library here starts from the -mt:false file an explicit + /// PurviewMultiThreadedBuild=false leaves behind, and a target that runs straight after its read + /// writes the client's form, standing in for the client building on another node. + /// + [Test] + public async Task Bootstrap_FromALibrary_KeepsAClientFileWrittenAfterItLooked(CancellationToken cancellationToken) + { + var clientRelativePath = $"{ClientName}/{ClientName}.csproj"; + var clientLines = ClientRsp(clientRelativePath).TrimEnd('\n').Split('\n'); + var interleave = $""" + + <_ClientRspLine Include="{string.Join(';', clientLines)}" /> + + + + + + + """; + using var library = await ProjectHarness + .For("Contracts") + .WithNamespacePrefix("Acme") + .WithProjectFileContent( + BlazorFixtures + .Library(items: BlazorFixtures.RemoveAutomaticPackages) + .Replace("", interleave, StringComparison.Ordinal) + ) + .WithPreImportPropertiesRaw(BootstrapOn) + .BuildAsync(cancellationToken); + var clientPath = Path.Combine(library.SolutionDirectory, ClientName, ClientName + ".csproj"); + Directory.CreateDirectory(Path.GetDirectoryName(clientPath)!); + await File.WriteAllTextAsync(clientPath, "", cancellationToken); + await File.WriteAllTextAsync( + RspPath(library), + "# Purview.BuildSdk - managed file\n# Managed by Purview.BuildSdk. Delete the marker line to take ownership of this file.\n-mt:false\n", + cancellationToken + ); + + var (code, output, errors) = await library.RunMSBuildAsync( + "-restore -t:Build -noautoresponse", + cancellationToken + ); + + await Assert.That(code).IsZero().Because(output + errors); + await Assert + .That(File.Exists(Path.Combine(Path.GetDirectoryName(library.ProjectFilePath)!, "interleaved.txt"))) + .IsTrue() + .Because("the stand-in for the client must run between the library's read and its write"); + await Assert.That(await ReadRspLinesAsync(library, cancellationToken)).IsEquivalentTo(clientLines); + } + + /// + /// Another project replacing the response file holds it open for writing for a moment. A project + /// reading it then must not fail the build with MSB4184: it retries, and leaves the file alone + /// if it is still busy. + /// + [Test] + public async Task Bootstrap_ResponseFileHeldOpenForWriting_DoesNotFailTheBuild(CancellationToken cancellationToken) + { + using var library = await CreateLibraryAsync(cancellationToken); + var clientPath = Path.Combine(library.SolutionDirectory, ClientName, ClientName + ".csproj"); + Directory.CreateDirectory(Path.GetDirectoryName(clientPath)!); + await File.WriteAllTextAsync(clientPath, "", cancellationToken); + + var written = ClientRsp($"{ClientName}/{ClientName}.csproj"); + await File.WriteAllTextAsync(RspPath(library), written, cancellationToken); + + int code; + string output; + string errors; + await using (new FileStream(RspPath(library), FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) + { + (code, output, errors) = await library.RunMSBuildAsync( + "-restore -t:Build -noautoresponse", + cancellationToken + ); + } + + await Assert.That(code).IsZero().Because(output + errors); + await Assert.That(output + errors).DoesNotContain("MSB4184"); + await Assert.That(await File.ReadAllTextAsync(RspPath(library), cancellationToken)).IsEqualTo(written); + } + + /// Once the client the file names is gone, the repository is back on -mt. + [Test] + public async Task Bootstrap_FromALibrary_RestoresMtWhenTheNamedClientIsGone(CancellationToken cancellationToken) + { + using var library = await CreateLibraryAsync(cancellationToken); + await File.WriteAllTextAsync( + RspPath(library), + ClientRsp($"{ClientName}/{ClientName}.csproj"), + cancellationToken + ); + + var (success, output, errors) = await library.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + var lines = await ReadRspLinesAsync(library, cancellationToken); + await Assert.That(lines).Contains("-mt"); + await Assert.That(lines).DoesNotContain("-mt:false"); + } + + const string BootstrapOn = + "true"; + + static string ReasonLine(string relativeProjectPath) => + $"# -mt:false: the Blazor WebAssembly client '{relativeProjectPath}' cannot build multithreaded (PRBLZ0002)."; + + static string ClientReasonLine(ProjectHarness client) => + ReasonLine(Path.GetRelativePath(client.SolutionDirectory, client.ProjectFilePath).Replace('\\', '/')); + + static string ClientRsp(string relativeProjectPath) => + $"# Purview.BuildSdk - managed file\n# Managed by Purview.BuildSdk. Delete the marker line to take ownership of this file.\n{ReasonLine(relativeProjectPath)}\n-mt:false\n"; + + static string RspPath(ProjectHarness harness) => Path.Combine(harness.SolutionDirectory, "Directory.Build.rsp"); + + static Task ReadRspLinesAsync(ProjectHarness harness, CancellationToken cancellationToken) => + File.ReadAllLinesAsync(RspPath(harness), cancellationToken); + + static async Task CreateClientAsync( + string? preImportProperties, + CancellationToken cancellationToken, + string targetFramework = "net11.0", + string webAssemblyVersion = "11.0.*" + ) + { + var builder = ProjectHarness + .For(ClientName) + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Client(targetFramework: targetFramework)); + if (preImportProperties is not null) + builder.WithPreImportPropertiesRaw(preImportProperties); + + var client = await builder.BuildAsync(cancellationToken); + await BlazorFixtures.WritePackageVersionsAsync(client, cancellationToken, webAssemblyVersion); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", BlazorFixtures.ClientProgram, cancellationToken); + return client; + } + + static Task CreateLibraryAsync(CancellationToken cancellationToken) => + ProjectHarness + .For("Contracts") + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Library(items: BlazorFixtures.RemoveAutomaticPackages)) + .WithPreImportPropertiesRaw(BootstrapOn) + .BuildAsync(cancellationToken); +} diff --git a/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs index 71ae119..b758fba 100644 --- a/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs @@ -71,8 +71,9 @@ public async Task BrowserSafe_AppliesTheBrowserSafeDefaults(CancellationToken ca await Assert.That(warningsAsErrors).Contains("IL2026"); await Assert.That(warningsAsErrors).Contains("IL2057"); await Assert.That(warningsAsErrors).Contains("IL3050"); - // The aim is to make *these* failures fatal, not to change the project's whole warning policy. - await Assert.That(evaluation.Properties["TreatWarningsAsErrors"]).IsNotEqualTo("true"); + // The SDK-wide default. The browser-safe ids are listed individually as well, so they stay fatal in a + // project that turns TreatWarningsAsErrors off (BrowserSafe_KeepsWhatTheProjectSet). + await Assert.That(evaluation.Properties["TreatWarningsAsErrors"]).IsEqualTo("true"); } [Test] @@ -85,6 +86,7 @@ public async Task BrowserSafe_KeepsWhatTheProjectSet(CancellationToken cancellat true false CS0168 + false """, """""" ), @@ -92,13 +94,15 @@ public async Task BrowserSafe_KeepsWhatTheProjectSet(CancellationToken cancellat ); var evaluation = await h.EvaluateAsync( - ["IsAotCompatible", "WarningsAsErrors"], + ["IsAotCompatible", "WarningsAsErrors", "TreatWarningsAsErrors"], ["SupportedPlatform"], cancellationToken ); await Assert.That(evaluation.Properties["IsAotCompatible"]).IsEqualTo("false"); await Assert.That(evaluation.Properties["WarningsAsErrors"]).StartsWith("CS0168;"); + await Assert.That(evaluation.Properties["WarningsAsErrors"]).Contains("CA1416"); + await Assert.That(evaluation.Properties["TreatWarningsAsErrors"]).IsEqualTo("false"); await Assert.That(evaluation.Items["SupportedPlatform"].Count(p => p == "browser")).IsEqualTo(1); } @@ -209,8 +213,10 @@ public async Task BrowserSafe_ProcessStart_FailsTheBuild(CancellationToken cance [Test] public async Task BrowserSafeWarningsAsErrorsFalse_ReflectionIsAWarning(CancellationToken cancellationToken) { + // The opt-out hands the ids back to the project's own warning policy, so that policy has to allow + // warnings for one to be seen. using var h = await CreateLibraryAsync( - "truefalse", + "truefalsefalse", ReflectionSource, cancellationToken ); diff --git a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs deleted file mode 100644 index cea99ce..0000000 --- a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Purview.BuildSdk.Harness; - -namespace Purview.BuildSdk; - -/// -/// Verifies SDK properties are exported as CompilerVisibleProperty items so Roslyn -/// analyzers/source generators can consume them via build_property.*. -/// -sealed class CompilerVisiblePropertyTests -{ - [Test] - public async Task CompilerVisibleProperties_Include_AllSdkProperties(CancellationToken cancellationToken) - { - using var h = await ProjectHarness.CreateAsync("MyLibrary", cancellationToken: cancellationToken); - var compilerVisibleProperties = await h.GetItemIdentitiesAsync("CompilerVisibleProperty", cancellationToken); - - var expected = new[] - { - "UsePackageJsonVersion", - "RootPackageJson", - "RepoRoot", - "Version", - "PackageVersion", - "NamespacePrefix", - "DisableNamespacePrefixCheck", - "TestingFramework", - "SubstituteFramework", - "TestDataFramework", - "SourceLinkPackageName", - "ExcludePurviewTelemetry", - "ExcludeMSTelemetryExtension", - "PurviewRepoBootstrapMode", - "PurviewRepoBootstrapCopyRetries", - "PurviewRepoBootstrapCopyRetryDelayMilliseconds", - "PurviewRepoBootstrapCopyFailureAsError", - "PurviewSuppressCopyRetryWarnings", - "DisableGenerateAssemblyInfoClass", - "DisableGenerateAssemblyIdentifierClass", - "EnableAssemblyNameGeneration", - "DisableAutoInternalsVisibleTo", - "AutoIncludeUsings", - "IsCSharpProject", - "IsTestProject", - "IsSharedTestingProject", - "TestingType", - "TargetProjectName", - "IsContainerProject", - "IsSdkProject", - "SdkProjectName", - "IsWebProject", - "IsWebSdkProject", - "IsWorkerSdkProject", - "IsAspireHostProject", - "IsBlazorWebAssemblyProject", - "IsRazorClassLibraryProject", - "IsBlazorHostProject", - "IsBffProject", - "PurviewBrowserSafe", - "PurviewBrowserSafeWarningsAsErrors", - "DisablePurviewBrowserSafeReferenceCheck", - "PurviewBrowserSafeReferenceCheckAsError", - "DisablePurviewBlazorWebAssemblyDefaults", - "PurviewAutoBlazorClientReference", - "PurviewBffExposesApi", - "IsSharedProject", - "IsServerSharedProject", - "IsClientSideProject", - "PurviewAutoServerSharedReference", - "DisablePurviewServerSharedReferenceCheck", - "PurviewServerSharedReferenceCheckAsError", - "MemorySafetyRules", - "PurviewRuntimeAsync", - "PurviewMultiThreadedBuild", - "RepositoryDirectoryBuildRspFilePath", - "BootstrapMultiThreadedBuildRspToRepoRoot", - "EditorConfigFilePath", - "CurrentYear", - "AutoGeneratedAssemblyInfoFile", - "AutoGeneratedAssemblyIdentifierFile", - "AssemblyIdentifierClassName", - }; - - foreach (var propertyName in expected) - await Assert.That(compilerVisibleProperties).Contains(propertyName); - } -} diff --git a/src/tests/BuildSdk.IntegrationTests/CoreDefaultsTests.cs b/src/tests/BuildSdk.IntegrationTests/CoreDefaultsTests.cs index 8ccfb2f..f10b8b6 100644 --- a/src/tests/BuildSdk.IntegrationTests/CoreDefaultsTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/CoreDefaultsTests.cs @@ -80,6 +80,32 @@ await Assert .Because("EnforceCodeStyleInBuild should default to true"); } + /// + /// Warnings are errors in every project, not only in Roslyn components: a warning nobody is made to + /// look at is one that ships. + /// + [Test] + [Arguments("MyLibrary")] + [Arguments("MyLibrary.UnitTests")] + public async Task DefaultProject_TreatsWarningsAsErrors(string projectName, CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync(projectName, cancellationToken: cancellationToken); + + await Assert.That(await h.GetPropertyAsync("TreatWarningsAsErrors", cancellationToken)).IsEqualTo("true"); + } + + [Test] + public async Task TreatWarningsAsErrors_SetFalseInTheProject_Wins(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "MyLibrary", + extraProps: "false", + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("TreatWarningsAsErrors", cancellationToken)).IsEqualTo("false"); + } + [Test] public async Task DefaultProject_EditorConfig_ResolvesSdkEditorConfigAndListsIt(CancellationToken cancellationToken) { diff --git a/src/tests/BuildSdk.IntegrationTests/DocumentationWikiTests.cs b/src/tests/BuildSdk.IntegrationTests/DocumentationWikiTests.cs new file mode 100644 index 0000000..8fa1844 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/DocumentationWikiTests.cs @@ -0,0 +1,446 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Purview.BuildSdk; + +/// +/// Guards the shape of the documentation set in docs/wiki: every page sits at the root or in one topic +/// folder, is listed once in _Sidebar.md, is reachable from Home.md, and its links resolve. +/// +/// +/// +/// The folder tree is load-bearing, not decorative: purview.dev publishes each page at +/// /docs/build-sdk/<folder>/<page>/ and resolves every relative link against the page that +/// contains it, so a page's location is part of its URL and a link is only correct relative to the file it is +/// written in. These tests read the tree the way the sync does - paths relative to docs/wiki, links +/// resolved from the containing file - which is why they catch a page that exists but is published nowhere +/// discoverable. +/// +/// +/// The rules are the published purview-buildsdk-docs-sync skill's; every assertion names the offending +/// file, because the failure mode is silence: the site publishes whatever it finds, in whatever order it finds it. +/// +/// +sealed partial class DocumentationWikiTests +{ + const string SidebarFile = "_Sidebar.md"; + const string HomeFile = "Home.md"; + const string LandingPage = "Getting-Started.md"; + + /// + /// The pages that must stay at the wiki root: the site's landing page (docs.rootPage) and the two pages + /// its project catalogue deep-links through docsPage (configuration-reference, + /// repository-bootstrap). A deep link is a single path segment, so those pages keep root-level URLs. + /// + static readonly string[] RootPages = + [ + "Configuration-Reference.md", + "Getting-Started.md", + "Repository-Bootstrap.md", + ]; + + /// The topic folders, one per Home.md section that is not at the root. + static readonly string[] TopicFolders = + [ + "analyzers", + "concepts", + "configuration", + "development", + "packaging", + "testing", + "tooling", + ]; + + /// Repository documents outside the wiki whose links into it must resolve. + static readonly string[] RepositoryDocuments = ["README.md", "AGENTS.md"]; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + static string WikiFolder => Path.Combine(RepositoryRoot.Value, "docs", "wiki"); + + [Test] + public async Task WikiFolder_ResolvesToThisRepository() + { + var indexFiles = new[] { SidebarFile, HomeFile, LandingPage } + .Where(name => File.Exists(Path.Combine(WikiFolder, name))) + .ToArray(); + + // Without the marker walk landing here, every other test in this class passes vacuously against files + // that were never found. + await Assert + .That(indexFiles) + .IsEquivalentTo([SidebarFile, HomeFile, LandingPage]) + .Because($"'{WikiFolder}' does not look like this repository's docs/wiki folder"); + + await Assert.That(PageFiles()).IsNotEmpty(); + } + + [Test] + public async Task Pages_LiveAtTheRootOrInOneTopicFolder() + { + var pages = PageFiles(); + + // A page's folder is the first segment of its published URL, so a second level of nesting would make the + // URL say more about the wiki's filing than about the page. + var tooDeep = pages.Where(path => path.Split('/').Length > 2).ToArray(); + var folders = pages + .Select(path => path.Split('/')) + .Where(segments => segments.Length > 1) + .Select(segments => segments[0]) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + await Assert + .That(tooDeep) + .IsEmpty() + .Because("every page belongs at the wiki root or in exactly one folder: " + string.Join(", ", tooDeep)); + + await Assert + .That(folders) + .IsEquivalentTo(TopicFolders) + .Because( + "a new topic folder needs a sidebar section, a Home.md heading and an entry here; found: " + + string.Join(", ", folders) + ); + } + + [Test] + public async Task RootPages_AreExactlyTheSiteContractPages() + { + var rootPages = PageFiles().Where(path => !path.Contains('/', StringComparison.Ordinal)).ToArray(); + + var unexpected = rootPages.Except(RootPages, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + var missing = RootPages.Except(rootPages, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + + // Moving a deep-linked page changes a published URL that the site catalogue - another repository - points at. + await Assert + .That(unexpected) + .IsEmpty() + .Because( + "a page at the wiki root needs a site contract behind it (docs.rootPage or a docsPage deep link); " + + "otherwise it belongs in a topic folder: " + + string.Join(", ", unexpected) + ); + + await Assert + .That(missing) + .IsEmpty() + .Because("these pages must stay at the wiki root: " + string.Join(", ", missing)); + } + + [Test] + public async Task Pages_UseUrlSafeNames() + { + // A page's folder and file name become its URL, lowercased: `packaging/SourceLink.md` is published at + // /docs/build-sdk/packaging/sourcelink/. A space, a dot or a non-ASCII character would be rewritten by the + // sync, so the address would stop mirroring the tree. + var unsafeNames = PageFiles() + .SelectMany(path => path.Split('/')) + .Select(segment => segment.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? segment[..^3] : segment) + .Where(segment => !UrlSafeNamePattern().IsMatch(segment)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + await Assert + .That(unsafeNames) + .IsEmpty() + .Because( + "folders and page names become URL segments verbatim, so they must be letters, digits and dashes: " + + string.Join(", ", unsafeNames) + ); + } + + [Test] + public async Task Sidebar_ListsEveryPageExactlyOnce(CancellationToken cancellationToken) + { + var sidebar = await ReadWikiFileAsync(SidebarFile, cancellationToken); + var listed = PageTargets(sidebar, SidebarFile); + var pages = PageFiles(); + + var unlisted = pages.Except(listed, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + var duplicated = listed + .GroupBy(path => path, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => $"{group.Key} (listed {group.Count()} times)") + .Order(StringComparer.Ordinal) + .ToArray(); + + // The sidebar is the page-order authority for the published site, so an unlisted page is published last, + // and a duplicated one has two competing positions. + await Assert + .That(unlisted) + .IsEmpty() + .Because( + $"every page must appear in {SidebarFile}, inside its section's list, by its path from docs/wiki: " + + string.Join(", ", unlisted) + ); + + await Assert + .That(duplicated) + .IsEmpty() + .Because($"a page may appear in {SidebarFile} only once: " + string.Join(", ", duplicated)); + } + + [Test] + public async Task Home_LinksEveryPage(CancellationToken cancellationToken) + { + var home = await ReadWikiFileAsync(HomeFile, cancellationToken); + var linked = PageTargets(home, HomeFile); + + // Home.md is the index a reader browses the folders with, so a page missing from it is invisible to anyone + // who did not already know its name. Only reachability is asserted; exactly-once ordering is the sidebar's. + var unlisted = PageFiles().Except(linked, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + + await Assert + .That(unlisted) + .IsEmpty() + .Because($"every page must appear in {HomeFile}: " + string.Join(", ", unlisted)); + } + + [Test] + public async Task Home_HasAHeadingForEverySidebarSection(CancellationToken cancellationToken) + { + var sidebar = StripCode(await ReadWikiFileAsync(SidebarFile, cancellationToken)); + var home = StripCode(await ReadWikiFileAsync(HomeFile, cancellationToken)); + + // The labels are what the sidebar shows and the headings are what Home.md shows; a section renamed in one + // place only leaves the two disagreeing about what the wiki contains. + var labels = SectionLabelPattern().Matches(sidebar).Select(match => match.Groups[1].Value.Trim()).ToArray(); + var headings = Heading2Pattern().Matches(home).Select(match => match.Groups[1].Value.Trim()).ToArray(); + var unmatched = labels.Except(headings, StringComparer.Ordinal).ToArray(); + + await Assert.That(labels).IsNotEmpty(); + await Assert + .That(unmatched) + .IsEmpty() + .Because( + $"every '{SidebarFile}' section needs a matching '## ' heading in {HomeFile}: " + + string.Join(", ", unmatched) + ); + } + + [Test] + public async Task WikiLinks_ResolveToExistingPages(CancellationToken cancellationToken) + { + List broken = []; + + foreach (var file in PageFiles().Append(SidebarFile).Append(HomeFile)) + { + var markdown = StripCode(await ReadWikiFileAsync(file, cancellationToken)); + foreach (var target in MarkdownLinkTargets(markdown)) + { + var path = StripAnchor(target); + if (!IsRelativeMarkdownLink(path)) + continue; + + // A link that climbs out of docs/wiki is a repository path, which the site rewrites to a GitHub URL; + // it must still resolve in the repository. + var resolved = Path.GetFullPath( + Path.Combine(Path.GetDirectoryName(Path.Combine(WikiFolder, file))!, path) + ); + if (!File.Exists(resolved)) + broken.Add($"{file} -> {target}"); + } + } + + // A link is resolved against the page it appears on, so a page that moved without its incoming links being + // updated breaks only for the reader who follows it. + await Assert + .That(broken) + .IsEmpty() + .Because("these links point at files that do not exist: " + string.Join(", ", broken)); + } + + [Test] + public async Task RepositoryDocuments_LinksIntoTheWikiResolve(CancellationToken cancellationToken) + { + List broken = []; + + foreach (var document in RepositoryDocuments) + { + var markdown = StripCode( + ( + await File.ReadAllTextAsync(Path.Combine(RepositoryRoot.Value, document), cancellationToken) + ).ReplaceLineEndings("\n") + ); + + foreach (var target in MarkdownLinkTargets(markdown)) + { + var path = StripAnchor(target); + if (!IsRelativeMarkdownLink(path) || !path.Contains("docs/wiki/", StringComparison.Ordinal)) + continue; + + if (!File.Exists(Path.Combine(RepositoryRoot.Value, path.Replace('/', Path.DirectorySeparatorChar)))) + broken.Add($"{document} -> {target}"); + } + } + + // Moving a wiki page into a topic folder leaves every README and AGENTS.md link to its old path pointing at + // nothing, and GitHub renders a broken link without complaint. + await Assert + .That(broken) + .IsEmpty() + .Because("these links into docs/wiki point at pages that do not exist: " + string.Join(", ", broken)); + } + + [Test] + public async Task Pages_OpenWithASingleH1Title(CancellationToken cancellationToken) + { + List problems = []; + + foreach (var page in PageFiles()) + { + var markdown = StripCode(await ReadWikiFileAsync(page, cancellationToken)); + var lines = markdown.Split('\n').Where(line => !string.IsNullOrWhiteSpace(line)).ToArray(); + var titles = lines.Count(line => Heading1Pattern().IsMatch(line)); + + if (titles != 1) + problems.Add($"{page} has {titles} '# ' headings"); + else if (!Heading1Pattern().IsMatch(lines[0])) + problems.Add($"{page} does not open with its '# ' heading"); + } + + // The first heading is the title the published sidebar shows, so a page without exactly one is either + // untitled or titled with whichever section heading happens to come first. + await Assert.That(problems).IsEmpty().Because(string.Join("; ", problems)); + } + + /// Every page in the wiki, as a path relative to docs/wiki with / separators. + static string[] PageFiles() => + [ + .. Directory + .GetFiles(WikiFolder, "*.md", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(WikiFolder, path).Replace('\\', '/')) + .Where(path => path is not SidebarFile and not HomeFile) + .Order(StringComparer.Ordinal), + ]; + + /// + /// The pages a document links to, as paths relative to docs/wiki, resolved against the document's own + /// folder - which is what both the site and GitHub do. A link that climbs above the wiki folder is dropped. + /// + static string[] PageTargets(string markdown, string fromPath) => + [ + .. MarkdownLinkTargets(StripCode(markdown)) + .Select(StripAnchor) + .Where(IsRelativeMarkdownLink) + .Select(target => ResolveWithinWiki(fromPath, target)) + .OfType(), + ]; + + /// Resolves a link the way a browser does, or null when it points outside the wiki folder. + static string? ResolveWithinWiki(string fromPath, string target) + { + List segments = [.. fromPath.Split('/')[..^1]]; + + foreach (var segment in target.Split('/')) + { + if (segment is "" or ".") + continue; + + if (segment == "..") + { + if (segments.Count == 0) + return null; + + segments.RemoveAt(segments.Count - 1); + continue; + } + + segments.Add(segment); + } + + return segments.Count == 0 ? null : string.Join('/', segments); + } + + static async Task ReadWikiFileAsync(string path, CancellationToken cancellationToken) => + ( + await File.ReadAllTextAsync( + Path.Combine(WikiFolder, path.Replace('/', Path.DirectorySeparatorChar)), + cancellationToken + ) + ) + // Line endings are normalised so a pattern does not have to tolerate CRLF; the files are checked out with + // whatever the machine's autocrlf setting produces. + .ReplaceLineEndings("\n"); + + /// + /// Removes fenced blocks and inline code spans. Neither is markdown structure - a quoted # comment or a + /// quoted link is an example - so reading them would report problems the reader never sees. + /// + static string StripCode(string markdown) + { + StringBuilder builder = new(markdown.Length); + var inFence = false; + + foreach (var line in markdown.Split('\n')) + { + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) + { + inFence = !inFence; + continue; + } + + if (!inFence) + builder.Append(InlineCodePattern().Replace(line, string.Empty)).Append('\n'); + } + + return builder.ToString(); + } + + static string StripAnchor(string target) => target.Split('#', 2)[0].Split('?', 2)[0]; + + static string[] MarkdownLinkTargets(string markdown) => + [.. LinkPattern().Matches(markdown).Select(match => match.Groups[1].Value.Trim())]; + + /// Whether a link target names a markdown file: relative, and not an external URL. + static bool IsRelativeMarkdownLink(string path) => + path.Length > 0 + && path is not ['/', ..] + && !path.Contains("://", StringComparison.Ordinal) + && path.EndsWith(".md", StringComparison.OrdinalIgnoreCase); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + var wiki = Path.Combine(directory.FullName, "docs", "wiki"); + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(wiki, SidebarFile)) + && File.Exists(Path.Combine(wiki, HomeFile)) + ) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"No 'docs/wiki' carrying {SidebarFile} and {HomeFile} above '{AppContext.BaseDirectory}'." + ); + } + + [GeneratedRegex(@"!?\[[^\]]*\]\(([^)\s]+)\)")] + private static partial Regex LinkPattern(); + + [GeneratedRegex(@"`[^`\n]*`")] + private static partial Regex InlineCodePattern(); + + [GeneratedRegex(@"^#\s", RegexOptions.Multiline)] + private static partial Regex Heading1Pattern(); + + [GeneratedRegex(@"^##\s+(.+?)\s*$", RegexOptions.Multiline)] + private static partial Regex Heading2Pattern(); + + [GeneratedRegex(@"^[ \t]*-[ \t]+\*\*(.+?)\*\*[ \t]*$", RegexOptions.Multiline)] + private static partial Regex SectionLabelPattern(); + + [GeneratedRegex(@"^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$")] + private static partial Regex UrlSafeNamePattern(); +} diff --git a/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs b/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs index 21899e4..bffda1f 100644 --- a/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs @@ -17,11 +17,15 @@ sealed class FieldNamingPolicyTests """; + // The assertions read IDE1006 from the build output, so it has to stay a warning the build survives. + const string WarningsAllowed = "false"; + [Test] public async Task PrivateInstanceField_WithoutUnderscorePrefix_IsReported(CancellationToken cancellationToken) { using var h = await ProjectHarness.CreateAsync( "MyApp", + extraProps: WarningsAllowed, extraItems: RemoveTelemetryItems, cancellationToken: cancellationToken ); @@ -40,6 +44,7 @@ public async Task PrivateInstanceField_WithUnderscorePrefix_IsNotReported(Cancel { using var h = await ProjectHarness.CreateAsync( "MyApp", + extraProps: WarningsAllowed, extraItems: RemoveTelemetryItems, cancellationToken: cancellationToken ); @@ -61,6 +66,7 @@ public async Task PrivateStaticReadonlyField_WithUnderscorePrefix_IsReported(Can { using var h = await ProjectHarness.CreateAsync( "MyApp", + extraProps: WarningsAllowed, extraItems: RemoveTelemetryItems, cancellationToken: cancellationToken ); @@ -83,6 +89,7 @@ public async Task PrivateStaticReadonlyField_PascalCase_IsNotReported(Cancellati { using var h = await ProjectHarness.CreateAsync( "MyApp", + extraProps: WarningsAllowed, extraItems: RemoveTelemetryItems, cancellationToken: cancellationToken ); @@ -104,6 +111,7 @@ public async Task PrivateConstField_PascalCase_IsNotReported(CancellationToken c { using var h = await ProjectHarness.CreateAsync( "MyApp", + extraProps: WarningsAllowed, extraItems: RemoveTelemetryItems, cancellationToken: cancellationToken ); diff --git a/src/tests/BuildSdk.IntegrationTests/JustModuleParityTests.cs b/src/tests/BuildSdk.IntegrationTests/JustModuleParityTests.cs new file mode 100644 index 0000000..92307dd --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/JustModuleParityTests.cs @@ -0,0 +1,160 @@ +using System.Text.RegularExpressions; + +namespace Purview.BuildSdk; + +/// +/// Holds this repository's .build/just/ modules to the published +/// purview-buildsdk-justfile skill's assets/just/, which they are copied from. +/// +/// +/// +/// The modules are written once, in the skill, and copied verbatim into every repository that uses +/// them - this one included. An edit made only to the .build/just/ copy would never reach a +/// consumer, and the next copy would silently undo it; an edit made only to the skill would ship +/// recipes this repository never ran. Comparing the two is the only thing that notices either. +/// +/// +/// The root Justfile sets allow-duplicate-recipes, so a recipe it defines with a +/// module's name replaces the module's without a word. That is how a hook is overridden, and also how +/// a stale copy of a migrated recipe would keep the old behaviour, so only _-prefixed hooks +/// may share a name. +/// +/// +sealed partial class JustModuleParityTests +{ + /// A recipe header at column 0: name params: deps, not an assignment. + [GeneratedRegex(@"^(?[A-Za-z_][A-Za-z0-9_-]*)(?:\s[^:\n]*)?:(?!=)", RegexOptions.Multiline)] + private static partial Regex RecipeHeaderPattern(); + + /// An unconditional import in the barrel - import "x.just", not import?. + [GeneratedRegex(@"^import\s+""(?[^""]+)""", RegexOptions.Multiline)] + private static partial Regex RequiredImportPattern(); + + /// Any import, optional or not. + [GeneratedRegex(@"^import\??\s+""(?[^""]+)""", RegexOptions.Multiline)] + private static partial Regex AnyImportPattern(); + + static readonly string[] NotRecipes = ["set", "import", "export", "mod", "alias"]; + + static string RepositoryRoot + { + get + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AGENTS.md"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException( + $"No AGENTS.md was found above '{AppContext.BaseDirectory}', so the repository root could not be resolved." + ); + } + } + + static string ModuleFolder => Path.Combine(RepositoryRoot, ".build", "just"); + + static string AssetFolder => Path.Combine(RepositoryRoot, "skills", "purview-buildsdk-justfile", "assets", "just"); + + /// Reads a file with line endings normalised, so a CRLF checkout compares equal. + static string ReadNormalised(string path) => File.ReadAllText(path).ReplaceLineEndings("\n"); + + static string[] FileNames(string folder) => + [.. Directory.GetFiles(folder).Select(Path.GetFileName).Select(name => name!).Order(StringComparer.Ordinal)]; + + static string[] RecipeNames(string justfileText) => + [ + .. RecipeHeaderPattern() + .Matches(justfileText) + .Select(match => match.Groups["name"].Value) + .Where(name => !NotRecipes.Contains(name, StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal), + ]; + + [Test] + public async Task EveryModule_MatchesTheSkillAsset() + { + var modules = FileNames(ModuleFolder); + await Assert.That(modules).IsNotEmpty(); + + var assets = FileNames(AssetFolder); + var unpublished = modules.Except(assets, StringComparer.Ordinal).ToArray(); + await Assert + .That(unpublished) + .IsEmpty() + .Because( + ".build/just/ holds files the purview-buildsdk-justfile skill does not ship - add them to its assets/just/, " + + "or keep repository-only recipes in the root Justfile: " + + string.Join(", ", unpublished) + ); + + foreach (var module in modules) + { + var copy = ReadNormalised(Path.Combine(ModuleFolder, module)); + var asset = ReadNormalised(Path.Combine(AssetFolder, module)); + + await Assert + .That(copy) + .IsEqualTo(asset) + .Because( + $".build/just/{module} differs from skills/purview-buildsdk-justfile/assets/just/{module}. " + + "Change the skill's copy and copy it over, never the other way round." + ); + } + } + + [Test] + public async Task EveryRequiredModule_IsPresent() + { + // The barrel's unconditional imports fail every recipe when a file is missing, so a repository + // may leave out only the import? modules. + var barrel = ReadNormalised(Path.Combine(ModuleFolder, "index.just")); + var required = RequiredImportPattern().Matches(barrel).Select(match => match.Groups["path"].Value).ToArray(); + + await Assert.That(required).IsNotEmpty(); + + foreach (var module in required) + { + await Assert + .That(File.Exists(Path.Combine(ModuleFolder, module))) + .IsTrue() + .Because($"index.just imports '{module}' unconditionally"); + } + } + + [Test] + public async Task RootJustfile_ImportsOnlyTheBarrel() + { + var root = ReadNormalised(Path.Combine(RepositoryRoot, "Justfile")); + var imports = AnyImportPattern().Matches(root).Select(match => match.Groups["path"].Value).ToArray(); + + await Assert.That(imports).IsEquivalentTo([".build/just/index.just"]); + } + + [Test] + public async Task RootJustfile_DoesNotRedefineAModuleRecipe() + { + var moduleRecipes = FileNames(ModuleFolder) + .Where(name => name.EndsWith(".just", StringComparison.Ordinal)) + .SelectMany(name => RecipeNames(ReadNormalised(Path.Combine(ModuleFolder, name)))) + .ToHashSet(StringComparer.Ordinal); + + await Assert.That(moduleRecipes).Contains("build"); + + var redefined = RecipeNames(ReadNormalised(Path.Combine(RepositoryRoot, "Justfile"))) + .Where(name => moduleRecipes.Contains(name) && name is not ['_', ..]) + .ToArray(); + + await Assert + .That(redefined) + .IsEmpty() + .Because( + "allow-duplicate-recipes makes a root recipe silently replace the module's; only _-prefixed hooks " + + "may be overridden: " + + string.Join(", ", redefined) + ); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchema/BuildSchemaDocument.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchema/BuildSchemaDocument.cs new file mode 100644 index 0000000..fc7610f --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchema/BuildSchemaDocument.cs @@ -0,0 +1,98 @@ +using System.Text.Json; + +namespace Purview.BuildSdk.PropertySchema; + +/// One entry under properties in a .buildschema.json sidecar. +sealed record BuildSchemaProperty( + string Name, + string Description, + string? DefaultValue, + string? IntrinsicType, + string? CustomTypeName, + IReadOnlyList Values, + bool AllowUnknownValues +); + +/// A parsed MSBuild Editor sidecar schema. +sealed class BuildSchemaDocument +{ + BuildSchemaDocument(string path, JsonElement root, IReadOnlyDictionary properties) + { + Path = path; + Root = root; + Properties = properties; + } + + public string Path { get; } + + public JsonElement Root { get; } + + public IReadOnlyDictionary Properties { get; } + + public static BuildSchemaDocument Load(string path) + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement.Clone(); + + Dictionary Values, bool AllowUnknown)> types = new(StringComparer.Ordinal); + if (root.TryGetProperty("types", out var typesElement)) + { + foreach (var type in typesElement.EnumerateObject()) + types[type.Name] = ReadValues(type.Value); + } + + Dictionary properties = new(StringComparer.Ordinal); + if (root.TryGetProperty("properties", out var propertiesElement)) + { + foreach (var property in propertiesElement.EnumerateObject()) + properties[property.Name] = ReadProperty(property.Name, property.Value, types); + } + + return new(path, root, properties); + } + + static BuildSchemaProperty ReadProperty( + string name, + JsonElement element, + Dictionary Values, bool AllowUnknown)> types + ) + { + if (element.ValueKind == JsonValueKind.String) + return new(name, element.GetString() ?? "", null, null, null, [], false); + + var description = element.TryGetProperty("description", out var d) ? d.GetString() ?? "" : ""; + var defaultValue = element.TryGetProperty("defaultValue", out var v) ? v.GetString() : null; + + string? intrinsic = null; + string? customName = null; + IReadOnlyList values = []; + var allowUnknown = false; + + if (element.TryGetProperty("type", out var type)) + { + if (type.ValueKind == JsonValueKind.String) + intrinsic = type.GetString(); + else if (type.ValueKind == JsonValueKind.Object && type.TryGetProperty("$ref", out var reference)) + { + customName = reference.GetString()?.Split('/').Last(); + if (customName is not null && types.TryGetValue(customName, out var shared)) + (values, allowUnknown) = shared; + } + else if (type.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + (values, allowUnknown) = ReadValues(type); + } + + return new(name, description, defaultValue, intrinsic, customName, values, allowUnknown); + } + + static (IReadOnlyList Values, bool AllowUnknown) ReadValues(JsonElement type) + { + if (type.ValueKind == JsonValueKind.Array) + return ([.. type.EnumerateArray().Select(e => e.GetString() ?? "")], false); + + var values = type.TryGetProperty("values", out var v) ? v.EnumerateObject().Select(p => p.Name).ToArray() : []; + var allowUnknown = type.TryGetProperty("allowUnknownValues", out var a) && a.GetBoolean(); + + return (values, allowUnknown); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchema/DraftFourValidator.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchema/DraftFourValidator.cs new file mode 100644 index 0000000..d716e34 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchema/DraftFourValidator.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Purview.BuildSdk.PropertySchema; + +/// +/// A JSON Schema validator for exactly the keywords the vendored MSBuild Editor meta-schema uses. +/// +/// +/// Hand-rolled rather than a package so the check needs no dependency and no network. It is kept +/// honest by : a meta-schema that starts using a keyword this class +/// does not implement fails a test, instead of being validated silently and incompletely. +/// const is supported although it is not draft-04, because the meta-schema uses it. +/// +sealed class DraftFourValidator(JsonElement root) +{ + /// Keywords that constrain an instance, and are implemented below. + public static readonly IReadOnlySet AssertionKeywords = new HashSet(StringComparer.Ordinal) + { + "$ref", + "additionalProperties", + "allOf", + "anyOf", + "const", + "enum", + "items", + "minLength", + "not", + "oneOf", + "pattern", + "patternProperties", + "properties", + "required", + "type", + }; + + /// Keywords that describe rather than constrain, so ignoring them is correct. + public static readonly IReadOnlySet AnnotationKeywords = new HashSet(StringComparer.Ordinal) + { + "$comment", + "$id", + "$schema", + "default", + "definitions", + "description", + "format", + "title", + }; + + /// Every keyword in a schema position of this class does not know. + public static IReadOnlyList UnsupportedKeywords(JsonElement schema) + { + SortedSet unsupported = new(StringComparer.Ordinal); + Visit(schema); + return [.. unsupported]; + + void Visit(JsonElement node) + { + if (node.ValueKind != JsonValueKind.Object) + return; + + foreach (var keyword in node.EnumerateObject()) + { + if (!AssertionKeywords.Contains(keyword.Name) && !AnnotationKeywords.Contains(keyword.Name)) + unsupported.Add(keyword.Name); + + if (keyword.Name is "properties" or "patternProperties" or "definitions") + { + foreach (var child in keyword.Value.EnumerateObject()) + Visit(child.Value); + } + else if (keyword.Name is "allOf" or "anyOf" or "oneOf") + { + foreach (var child in keyword.Value.EnumerateArray()) + Visit(child); + } + else if (keyword.Name is "items" or "not" or "additionalProperties") + Visit(keyword.Value); + } + } + } + + public IReadOnlyList Validate(JsonElement instance) + { + List errors = []; + Validate(root, instance, "$", errors); + return errors; + } + + void Validate(JsonElement schema, JsonElement instance, string path, List errors) + { + if (schema.ValueKind == JsonValueKind.True) + return; + + if (schema.ValueKind == JsonValueKind.False) + { + errors.Add($"{path}: is not allowed here."); + return; + } + + if (schema.TryGetProperty("$ref", out var reference)) + { + Validate(Resolve(reference.GetString()!), instance, path, errors); + return; + } + + if (schema.TryGetProperty("type", out var type) && !MatchesType(type, instance)) + { + errors.Add($"{path}: expected {type.GetRawText()}, found {instance.ValueKind}."); + return; + } + + ValidateValue(schema, instance, path, errors); + + if (instance.ValueKind == JsonValueKind.Object) + ValidateObject(schema, instance, path, errors); + + if (instance.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out var items)) + { + var index = 0; + foreach (var item in instance.EnumerateArray()) + Validate(items, item, $"{path}[{index++}]", errors); + } + + ValidateCombinators(schema, instance, path, errors); + } + + static void ValidateValue(JsonElement schema, JsonElement instance, string path, List errors) + { + if (schema.TryGetProperty("const", out var constant) && !JsonEquals(constant, instance)) + errors.Add($"{path}: must be {constant.GetRawText()}."); + + if ( + schema.TryGetProperty("enum", out var enumeration) + && !enumeration.EnumerateArray().Any(e => JsonEquals(e, instance)) + ) + errors.Add($"{path}: {instance.GetRawText()} is not one of {enumeration.GetRawText()}."); + + if (instance.ValueKind != JsonValueKind.String) + return; + + var text = instance.GetString()!; + if (schema.TryGetProperty("pattern", out var pattern) && !Regex.IsMatch(text, pattern.GetString()!)) + errors.Add($"{path}: '{text}' does not match /{pattern.GetString()}/."); + + if (schema.TryGetProperty("minLength", out var minLength) && text.Length < minLength.GetInt32()) + errors.Add($"{path}: must be at least {minLength.GetInt32()} characters."); + } + + void ValidateCombinators(JsonElement schema, JsonElement instance, string path, List errors) + { + if (schema.TryGetProperty("allOf", out var allOf)) + { + foreach (var branch in allOf.EnumerateArray()) + Validate(branch, instance, path, errors); + } + + if ( + schema.TryGetProperty("anyOf", out var anyOf) + && !anyOf.EnumerateArray().Any(b => IsValid(b, instance, path)) + ) + errors.Add($"{path}: matches none of the anyOf alternatives."); + + if (schema.TryGetProperty("oneOf", out var oneOf)) + { + var matches = oneOf.EnumerateArray().Count(b => IsValid(b, instance, path)); + if (matches != 1) + errors.Add($"{path}: matches {matches} of the oneOf alternatives; exactly one is required."); + } + + if (schema.TryGetProperty("not", out var not) && IsValid(not, instance, path)) + errors.Add($"{path}: matches a schema it must not match."); + } + + void ValidateObject(JsonElement schema, JsonElement instance, string path, List errors) + { + if (schema.TryGetProperty("required", out var required)) + { + foreach (var name in required.EnumerateArray().Select(r => r.GetString()!)) + { + if (!instance.TryGetProperty(name, out _)) + errors.Add($"{path}: is missing the required '{name}'."); + } + } + + var hasProperties = schema.TryGetProperty("properties", out var properties); + var hasPatterns = schema.TryGetProperty("patternProperties", out var patterns); + var hasAdditional = schema.TryGetProperty("additionalProperties", out var additional); + + foreach (var member in instance.EnumerateObject()) + { + var memberPath = $"{path}.{member.Name}"; + var matched = false; + + if (hasProperties && properties.TryGetProperty(member.Name, out var propertySchema)) + { + matched = true; + Validate(propertySchema, member.Value, memberPath, errors); + } + + if (hasPatterns) + { + foreach (var pattern in patterns.EnumerateObject()) + { + if (!Regex.IsMatch(member.Name, pattern.Name)) + continue; + + matched = true; + Validate(pattern.Value, member.Value, memberPath, errors); + } + } + + if (!matched && hasAdditional) + Validate(additional, member.Value, memberPath, errors); + } + } + + bool IsValid(JsonElement schema, JsonElement instance, string path) + { + List scratch = []; + Validate(schema, instance, path, scratch); + return scratch.Count == 0; + } + + JsonElement Resolve(string reference) + { + if (!reference.StartsWith("#/", StringComparison.Ordinal)) + throw new NotSupportedException($"Only local references are supported, not '{reference}'."); + + var node = root; + foreach (var segment in reference[2..].Split('/')) + node = node.GetProperty( + segment.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal) + ); + + return node; + } + + static bool MatchesType(JsonElement type, JsonElement instance) => + type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Any(t => MatchesType(t.GetString()!, instance)) + : MatchesType(type.GetString()!, instance); + + static bool MatchesType(string type, JsonElement instance) => + type switch + { + "object" => instance.ValueKind == JsonValueKind.Object, + "array" => instance.ValueKind == JsonValueKind.Array, + "string" => instance.ValueKind == JsonValueKind.String, + "boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False, + "number" => instance.ValueKind == JsonValueKind.Number, + "integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _), + "null" => instance.ValueKind == JsonValueKind.Null, + _ => throw new NotSupportedException($"Unknown JSON Schema type '{type}'."), + }; + + static bool JsonEquals(JsonElement left, JsonElement right) => JsonElement.DeepEquals(left, right); +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchema/MarkdownPropertyTables.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchema/MarkdownPropertyTables.cs new file mode 100644 index 0000000..739744f --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchema/MarkdownPropertyTables.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; + +namespace Purview.BuildSdk.PropertySchema; + +/// A property row in a markdown table: | `Name` | ... |. +sealed record MarkdownPropertyRow(string File, int Line, string Section, string Name, string? DefaultCell); + +/// +/// Reads the property tables in a markdown document. A table counts when its first column is a +/// backticked property name. Its default is read from a column headed Default, when it has one. +/// +static partial class MarkdownPropertyTables +{ + /// The heading of the table that lists every compiler-visible property. + public const string CompilerVisibleSection = "Compiler-visible SDK properties"; + + [GeneratedRegex(@"^#{1,6}\s+(?.+?)\s*$")] + private static partial Regex HeadingPattern(); + + [GeneratedRegex(@"^\|\s*`(?<name>[A-Za-z_][A-Za-z0-9_]*)`\s*\|")] + private static partial Regex PropertyRowPattern(); + + [GeneratedRegex(@"^`(?<literal>[^`]*)`")] + private static partial Regex LeadingLiteralPattern(); + + public static IReadOnlyList<MarkdownPropertyRow> Read(string path, string displayPath) + { + List<MarkdownPropertyRow> rows = []; + var section = ""; + var defaultColumn = -1; + var inPropertyTable = false; + var inFence = false; + var lines = File.ReadAllLines(path); + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) + { + inFence = !inFence; + continue; + } + + if (inFence) + continue; + + var heading = HeadingPattern().Match(line); + if (heading.Success) + { + section = heading.Groups["title"].Value; + defaultColumn = -1; + inPropertyTable = false; + continue; + } + + if (line.Length == 0 || line[0] != '|') + { + defaultColumn = -1; + inPropertyTable = false; + continue; + } + + var cells = SplitCells(line); + if (i + 1 < lines.Length && lines[i + 1].StartsWith("| --", StringComparison.Ordinal)) + { + // A header row. Only a table headed "Property" lists properties; diagnostic, target and + // mapping tables also open their rows with a backticked name. + inPropertyTable = cells[0] == "Property"; + defaultColumn = inPropertyTable ? Array.FindIndex(cells, c => c == "Default") : -1; + continue; + } + + var row = PropertyRowPattern().Match(line); + if (!inPropertyTable || !row.Success) + continue; + + var defaultCell = defaultColumn > 0 && defaultColumn < cells.Length ? cells[defaultColumn] : null; + rows.Add(new(displayPath, i + 1, section, row.Groups["name"].Value, defaultCell)); + } + + return rows; + } + + /// <summary> + /// The literal a default cell states: <c>`true`</c> and <c>`false` (`true` for X)</c> both state + /// <c>true</c>/<c>false</c>. A cell that opens with prose, such as <c>*(detected)*</c>, states none. + /// </summary> + public static string? StatedDefault(string? cell) + { + if (cell is null) + return null; + + var match = LeadingLiteralPattern().Match(cell); + return match.Success ? match.Groups["literal"].Value : null; + } + + /// <summary> + /// A list default is written without the SDK's internal <c>;</c> fences, so a document stating + /// <c>CA1002;CA1012</c> agrees with a value of <c>;CA1002;CA1012;</c>. + /// </summary> + public static string NormaliseList(string value) => value.Trim().Trim(';'); + + static string[] SplitCells(string line) + { + // Escaped pipes (\|) never appear in these tables; a plain split is enough. + var trimmed = line.Trim().Trim('|'); + return [.. trimmed.Split('|').Select(c => c.Trim())]; + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyClassification.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyClassification.cs new file mode 100644 index 0000000..8838572 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyClassification.cs @@ -0,0 +1,173 @@ +namespace Purview.BuildSdk.PropertySchema; + +/// <summary> +/// The deliberate exceptions to "every public property the SDK defines has a schema entry". Each one +/// carries its reason, and adding a name here is the only way to leave a property out of the schema. +/// </summary> +static class SdkPropertyClassification +{ + /// <summary> + /// Properties owned by MSBuild, the .NET SDK, NuGet or a test platform that this SDK defaults or + /// reads. MSBuild Editor already ships schemas for them, and how a sidecar entry merges with a + /// built-in one is unverified - so the sidecar does not redescribe them. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> ExternallyOwned = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["AccelerateBuildsInVisualStudio"] = "Visual Studio", + ["AllowedOutputExtensionsInPackageBuildOutputFolder"] = "NuGet pack", + ["AnalysisLevel"] = ".NET SDK", + ["AnalysisMode"] = ".NET SDK", + ["AssemblyName"] = "MSBuild", + ["BeforeMicrosoftNETSdkTargets"] = ".NET SDK import hook", + ["CentralPackageTransitivePinningEnabled"] = "NuGet", + ["CollectCoverage"] = "coverlet", + ["CompilerGeneratedFilesOutputPath"] = "Roslyn", + ["ContinuousIntegrationBuild"] = "MSBuild", + ["DebugType"] = "MSBuild", + ["Deterministic"] = "Roslyn", + ["DockerDefaultTargetOS"] = "Visual Studio container tools", + ["DockerfileContext"] = "Visual Studio container tools", + ["EmbedUntrackedSources"] = "SourceLink", + ["EnableDynamicNativeInstrumentation"] = "Microsoft.Testing.Platform", + ["EnableMicrosoftTestingPlatform"] = "Microsoft.Testing.Platform", + ["EnableNETAnalyzers"] = ".NET SDK", + ["EnableStaticNativeInstrumentation"] = "Microsoft.Testing.Platform", + ["EnforceCodeStyleInBuild"] = ".NET SDK", + ["EnforceExtendedAnalyzerRules"] = "Roslyn", + ["Exclude"] = "coverlet", + ["ExcludeByAttribute"] = "coverlet", + ["Features"] = "Roslyn", + ["GenerateAssemblyCompanyAttribute"] = ".NET SDK", + ["GenerateAssemblyCopyrightAttribute"] = ".NET SDK", + ["GenerateDependencyFile"] = ".NET SDK", + ["GenerateDocumentationFile"] = "MSBuild", + ["ImplicitUsings"] = ".NET SDK", + ["IncludeBuildOutput"] = "NuGet pack", + ["IncludeSource"] = "NuGet pack", + ["IncludeSymbols"] = "NuGet pack", + ["InterceptorsNamespaces"] = "Roslyn", + ["InvariantGlobalization"] = ".NET runtime", + ["IsAotCompatible"] = ".NET SDK", + ["IsPackable"] = "NuGet pack", + ["IsPublishable"] = ".NET SDK", + ["IsRoslynComponent"] = "Roslyn tooling", + ["IsTestingPlatformApplication"] = "Microsoft.Testing.Platform", + ["IsTestProject"] = ".NET SDK", + ["LangVersion"] = "Roslyn", + ["ManagePackageVersionsCentrally"] = "NuGet", + ["MaxCpuCount"] = "MSBuild", + ["MSBuildWarningsAsMessages"] = "MSBuild", + ["NoDefaultExcludes"] = "NuGet pack", + ["NoWarn"] = "Roslyn", + ["Nullable"] = "Roslyn", + ["OutputType"] = "MSBuild", + ["PackageId"] = "NuGet pack", + ["PackageReadmeFile"] = "NuGet pack", + ["PackageTags"] = "NuGet pack", + ["PackageVersion"] = "NuGet pack", + ["ProjectDir"] = "MSBuild", + ["PublishAot"] = ".NET SDK", + ["PublishRepositoryUrl"] = "SourceLink", + ["RootNamespace"] = "MSBuild", + ["SymbolPackageFormat"] = "NuGet pack", + ["TargetFramework"] = "MSBuild", + ["TargetFrameworks"] = "MSBuild", + ["TargetsForTfmSpecificContentInPackage"] = "NuGet pack", + ["TestingPlatformDotnetTestSupport"] = "Microsoft.Testing.Platform", + ["TreatWarningsAsErrors"] = "Roslyn", + ["UseMicrosoftTestingPlatformRunner"] = "Microsoft.Testing.Platform", + ["Version"] = "MSBuild", + ["WarningsAsErrors"] = "Roslyn", + ["WarnOnPackingNonPackableProject"] = "NuGet pack", + }; + + /// <summary> + /// SDK properties that predate the <c>_</c> prefix convention but are implementation detail: the + /// SDK computes them, and setting one is not supported. Renaming them would break anyone who reads + /// them, so they are excluded here instead of being offered in IntelliSense. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> Internal = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["DefaultTargetFramework"] = "derived from TargetFramework(s) in Sdk.targets", + ["Packable"] = "legacy placeholder in Defaults.props; nothing reads it", + ["PurviewEditorConfigVersionMarker"] = "the marker the SDK writes; changing it orphans every managed file", + ["PurviewLogicalProjectName"] = "computed during project-name parsing", + ["PurviewNamespacePrefix"] = "computed copy of NamespacePrefix", + ["PurviewPolicyExemptNoWarn"] = "records the SDK's own policy NoWarn entries for PRSGD0008", + ["PurviewProjectShortName"] = "computed during project-name parsing", + ["PurviewRepositoryRspVersionMarker"] = "the marker the SDK writes; changing it orphans every managed file", + ["PurviewTestType"] = "computed during test-type detection", + }; + + /// <summary> + /// Names referenced by the SDK but neither defined nor offered: aliases kept for compatibility. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> Aliases = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["UseRuntimeAsync"] = "alias for PurviewRuntimeAsync, documented there", + }; + + /// <summary> + /// Names a property table lists that are not SDK properties, so they have no schema entry. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> DocumentedNonProperties = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["AllowUnsafeBlocks"] = "the .NET 11 table states the memory-safety tiers never change it", + ["NamespaceRemoveSuffix"] = "an item type, listed beside the properties that shape RootNamespace", + }; + + /// <summary> + /// Schema entries whose value in the reference project depends on the machine, the date or the + /// project, so the schema states no <c>defaultValue</c>. Every other entry without one must evaluate + /// to empty. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> ComputedDefaults = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["CurrentYear"] = "the current year", + ["EditorConfigFilePath"] = "the SDK's install location", + ["NamespacePrefix"] = "required; the reference project has to set it", + ["PurviewBuildSdkVersionForGlobalJson"] = "the restored or packed SDK version", + ["VersionDetectionCacheFile"] = "derived from the repository path", + // Detection results: the value describes the project, so a "default" would only describe the + // reference project. + ["IsAspireHostProject"] = "detected", + ["IsBffProject"] = "detected", + ["IsBlazorHostProject"] = "detected", + ["IsBlazorWebAssemblyProject"] = "detected", + ["IsClientSideProject"] = "detected", + ["IsCLIProject"] = "detected", + ["IsContainerProject"] = "detected", + ["IsCSharpProject"] = "detected", + ["IsRazorClassLibraryProject"] = "detected", + ["IsSdkProject"] = "detected", + ["IsServerSharedProject"] = "detected", + ["IsSharedProject"] = "detected", + ["IsSharedTestingProject"] = "detected", + ["IsWebProject"] = "detected", + ["IsWebSdkProject"] = "detected", + ["IsWorkerSdkProject"] = "detected", + ["SdkProjectName"] = "detected", + }; + + /// <summary> + /// Schema entries the throwaway harness sets itself, so the reference project cannot observe the + /// shipped default for them. + /// </summary> + public static readonly IReadOnlyDictionary<string, string> SetByTheHarness = new Dictionary<string, string>( + StringComparer.Ordinal + ) + { + ["BootstrapMultiThreadedBuildRspToRepoRoot"] = + "ProjectHarness writes false so tests never change the build host", + }; +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyInventory.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyInventory.cs new file mode 100644 index 0000000..69f124b --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyInventory.cs @@ -0,0 +1,193 @@ +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace Purview.BuildSdk.PropertySchema; + +/// <summary>A property assignment in a shipped <c>.props</c>/<c>.targets</c> file.</summary> +sealed record SdkPropertyDefinition(string Name, string File, int Line, bool InTarget, string Value); + +/// <summary>A literal <c>CompilerVisibleProperty</c> item in a shipped file.</summary> +sealed record SdkCompilerVisibleProperty(string Name, string File, int Line); + +/// <summary> +/// A static read of every <c>.props</c>/<c>.targets</c> file under <c>Sdk/</c>: what is defined, what +/// is compiler-visible, and what is read while <c>Sdk.props</c> is evaluated. +/// </summary> +/// <remarks> +/// Static on purpose. Evaluation only reports the properties a given project happens to reach, so a +/// property defined under a condition the reference project does not meet would be invisible to it. +/// The XML is the complete list. +/// </remarks> +sealed partial class SdkPropertyInventory +{ + [GeneratedRegex(@"\$\((?<name>[A-Za-z_][A-Za-z0-9_]*)")] + private static partial Regex PropertyReferencePattern(); + + SdkPropertyInventory( + string sdkDirectory, + IReadOnlyList<SdkPropertyDefinition> definitions, + IReadOnlyList<SdkCompilerVisibleProperty> compilerVisibleProperties, + IReadOnlyDictionary<string, string> propsReads, + IReadOnlySet<string> references + ) + { + SdkDirectory = sdkDirectory; + Definitions = definitions; + CompilerVisibleProperties = compilerVisibleProperties; + PropsReads = propsReads; + References = references; + } + + public string SdkDirectory { get; } + + public IReadOnlyList<SdkPropertyDefinition> Definitions { get; } + + public IReadOnlyList<SdkCompilerVisibleProperty> CompilerVisibleProperties { get; } + + /// <summary> + /// Properties read during the <em>property</em> pass of <c>Sdk.props</c> (and the files it imports), + /// with the first <c>file:line</c> that reads each one. Those are the properties a project file sets + /// too late. A property's own <c>'$(X)' == ''</c> default guard is not a read. + /// </summary> + /// <remarks> + /// A read inside an <c>ItemGroup</c> or <c>ItemDefinitionGroup</c> - an item's <c>Include</c>, its + /// metadata, or the group's <c>Condition</c> - is not one: MSBuild evaluates every property, the + /// project body's included, before it evaluates a single item, so such a read sees the project's + /// value. Counting it made the schema tell people to set <c>SourceLinkPackageName</c> before the + /// import when a project file works. + /// </remarks> + public IReadOnlyDictionary<string, string> PropsReads { get; } + + /// <summary>Every property referenced as <c>$(Name)</c> anywhere in a shipped file.</summary> + public IReadOnlySet<string> References { get; } + + /// <summary>Public property names: defined in a shipped file, and not <c>_</c>-prefixed.</summary> + public IReadOnlySet<string> PublicDefinedNames => + Definitions.Select(d => d.Name).Where(n => n[0] != '_').ToHashSet(StringComparer.Ordinal); + + public SdkPropertyDefinition? FirstDefinition(string name) => + Definitions.FirstOrDefault(d => string.Equals(d.Name, name, StringComparison.Ordinal)); + + public bool IsDefinedOrRead(string name) => + Definitions.Any(d => string.Equals(d.Name, name, StringComparison.Ordinal)) || References.Contains(name); + + public string Relative(string path) => + Path.GetRelativePath(SdkDirectory, path).Replace(Path.DirectorySeparatorChar, '/'); + + public static SdkPropertyInventory Load(string sdkDirectory) + { + var files = Directory + .EnumerateFiles(sdkDirectory, "*.*", SearchOption.AllDirectories) + .Where(f => + f.EndsWith(".props", StringComparison.Ordinal) || f.EndsWith(".targets", StringComparison.Ordinal) + ) + .Order(StringComparer.Ordinal) + .ToArray(); + + var propsChain = ResolvePropsChain(Path.Combine(sdkDirectory, "Sdk.props")); + + List<SdkPropertyDefinition> definitions = []; + List<SdkCompilerVisibleProperty> compilerVisible = []; + Dictionary<string, string> propsReads = new(StringComparer.Ordinal); + HashSet<string> references = new(StringComparer.Ordinal); + + foreach (var file in files) + { + var relative = Path.GetRelativePath(sdkDirectory, file).Replace(Path.DirectorySeparatorChar, '/'); + var document = XDocument.Load(file, LoadOptions.SetLineInfo); + var inPropsChain = propsChain.Contains(Path.GetFullPath(file)); + + foreach (var element in document.Descendants()) + { + var line = ((IXmlLineInfo)element).LineNumber; + var parent = element.Parent?.Name.LocalName; + var inPropertyPass = !element + .AncestorsAndSelf() + .Any(a => a.Name.LocalName is "ItemGroup" or "ItemDefinitionGroup" or "Target"); + + if (parent == "PropertyGroup") + { + var inTarget = element.Ancestors().Any(a => a.Name.LocalName == "Target"); + definitions.Add(new(element.Name.LocalName, relative, line, inTarget, element.Value.Trim())); + } + else if ( + parent == "ItemGroup" + && element.Name.LocalName == "CompilerVisibleProperty" + && element.Attribute("Include")?.Value is { } include + && !include.StartsWith("@(", StringComparison.Ordinal) + ) + { + compilerVisible.Add(new(include, relative, line)); + } + + foreach (var text in ReferencingText(element)) + { + foreach (Match match in PropertyReferencePattern().Matches(text)) + { + var name = match.Groups["name"].Value; + references.Add(name); + + if (inPropsChain && inPropertyPass && parent != null && !IsOwnDefaultGuard(element, name, text)) + propsReads.TryAdd(name, $"{relative}:{line}"); + } + } + } + } + + return new(sdkDirectory, definitions, compilerVisible, propsReads, references); + } + + /// <summary>The element's own attributes and its direct text, so a nested element is read once.</summary> + static IEnumerable<string> ReferencingText(XElement element) + { + foreach (var attribute in element.Attributes()) + yield return attribute.Value; + + foreach (var node in element.Nodes()) + { + if (node is XText text) + yield return text.Value; + } + } + + /// <summary> + /// <c><X Condition="'$(X)' == ''"></c> reads <c>X</c> only to avoid overwriting it, which is + /// what makes a value set later still win. It is not a read that captures the value. + /// </summary> + static bool IsOwnDefaultGuard(XElement element, string name, string text) => + element.Parent?.Name.LocalName == "PropertyGroup" + && string.Equals(element.Name.LocalName, name, StringComparison.Ordinal) + && element.Attribute("Condition")?.Value == text + && Regex.Count(text, $@"\$\({Regex.Escape(name)}\)") == 1 + && Regex.IsMatch(text, $@"'\$\({Regex.Escape(name)}\)'\s*==\s*''"); + + /// <summary><c>Sdk.props</c> and every file it imports, transitively, by full path.</summary> + static HashSet<string> ResolvePropsChain(string sdkProps) + { + HashSet<string> chain = new(StringComparer.OrdinalIgnoreCase); + Stack<string> pending = new([Path.GetFullPath(sdkProps)]); + + while (pending.TryPop(out var file)) + { + if (!chain.Add(file) || !File.Exists(file)) + continue; + + var directory = Path.GetDirectoryName(file)!; + foreach (var import in XDocument.Load(file).Descendants().Where(e => e.Name.LocalName == "Import")) + { + var project = import.Attribute("Project")?.Value.Trim(); + if ( + string.IsNullOrEmpty(project) + || !project.StartsWith("$(MSBuildThisFileDirectory)", StringComparison.Ordinal) + ) + continue; + + var relative = project["$(MSBuildThisFileDirectory)".Length..]; + pending.Push(Path.GetFullPath(Path.Combine(directory, relative))); + } + } + + return chain; + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchemaTests.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchemaTests.cs new file mode 100644 index 0000000..fc85bcf --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchemaTests.cs @@ -0,0 +1,720 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.PropertySchema; + +namespace Purview.BuildSdk; + +/// <summary> +/// Keeps every description of an SDK property in step with the code. +/// +/// <c>Sdk/Sdk.props.buildschema.json</c> is the canonical description: MSBuild Editor reads it for +/// csproj IntelliSense, and these tests hold the code, the compiler-visible list, the README and the +/// wiki to it. Each failure names what to add and where. +/// +/// Everything here reads files, except <see cref="SchemaDefault_EveryStatedDefault_MatchesTheEvaluatedDefault"/>, +/// which evaluates a throwaway project. Nothing touches the network. +/// </summary> +sealed partial class PropertySchemaTests +{ + /// <summary>The upstream commit <c>Schemas/buildschema.json</c> was copied from.</summary> + public const string MetaSchemaCommit = "9720806e8fa80cd9c4cb31e12b01c63811f39a75"; + + /// <summary>The sentence a description uses to say the property must be set before the import.</summary> + public const string PreImportPhrase = "before the `Sdk.props` import"; + + /// <summary>The sentence a description uses to explain why there is no <c>defaultValue</c>.</summary> + public const string NoDefaultPhrase = "No default: "; + + const string SchemaFile = "src/src/BuildSdk/Sdk/Sdk.props.buildschema.json"; + + static readonly Lazy<SdkPropertyInventory> LazyInventory = new(() => + SdkPropertyInventory.Load(SdkPaths.SdkDirectory) + ); + + static readonly Lazy<BuildSchemaDocument> LazySchema = new(() => + BuildSchemaDocument.Load(Path.Combine(SdkPaths.SdkDirectory, "Sdk.props.buildschema.json")) + ); + + static SdkPropertyInventory Inventory => LazyInventory.Value; + + static BuildSchemaDocument Schema => LazySchema.Value; + + [GeneratedRegex(@"""build_property\.(?<name>[A-Za-z_][A-Za-z0-9_]*)""")] + private static partial Regex BuildPropertyReadPattern(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespacePattern(); + + static readonly string[] AnalyzerProjects = ["Analyzers", "CodeFixers"]; + + static string RepositoryRoot + { + get + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AGENTS.md"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException($"No AGENTS.md was found above '{AppContext.BaseDirectory}'."); + } + } + + static bool IsExcluded(string name) => + SdkPropertyClassification.ExternallyOwned.ContainsKey(name) + || SdkPropertyClassification.Internal.ContainsKey(name) + || SdkPropertyClassification.Aliases.ContainsKey(name); + + // --------------------------------------------------------------------------------------------- + // Schema versus the shipped .props/.targets files + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Schema_EveryDefinedSdkProperty_HasAnEntry() + { + // Arrange + var defined = Inventory.PublicDefinedNames.Where(n => !IsExcluded(n)); + + // Act + var missing = defined + .Where(n => !Schema.Properties.ContainsKey(n)) + .Select(n => + { + var definition = Inventory.FirstDefinition(n)!; + return $"'{n}' is defined at Sdk/{definition.File}:{definition.Line} but has no entry in {SchemaFile}. " + + "Add it under \"properties\" with a description, type and defaultValue. If it is internal, " + + "prefix it with '_' or add it to SdkPropertyClassification.Internal with a reason."; + }) + .ToArray(); + + // Assert + await Assert.That(missing).IsEmpty().Because(string.Join(Environment.NewLine, missing)); + } + + [Test] + public async Task Schema_EveryEntry_IsDefinedOrReadByTheSdk() + { + // Arrange + var entries = Schema.Properties.Keys; + + // Act + var orphaned = entries + .Where(n => !Inventory.IsDefinedOrRead(n)) + .Select(n => + $"{SchemaFile} describes '{n}', but no shipped .props/.targets file defines or reads it. " + + "Remove the entry, or define the property (with its default) in Sdk/Props/Defaults.props." + ) + .ToArray(); + + // Assert + await Assert.That(orphaned).IsEmpty().Because(string.Join(Environment.NewLine, orphaned)); + } + + [Test] + public async Task Schema_NoEntry_DescribesAnExcludedProperty() + { + // Arrange + var entries = Schema.Properties.Keys; + + // Act + var excluded = entries + .Where(IsExcluded) + .Select(n => + $"{SchemaFile} describes '{n}', which SdkPropertyClassification excludes. Remove one or the other." + ) + .ToArray(); + + // Assert + await Assert.That(excluded).IsEmpty().Because(string.Join(Environment.NewLine, excluded)); + } + + [Test] + public async Task Classification_EveryExclusion_NamesARealProperty() + { + // Arrange + var exclusions = SdkPropertyClassification + .ExternallyOwned.Keys.Concat(SdkPropertyClassification.Internal.Keys) + .Concat(SdkPropertyClassification.Aliases.Keys); + + var exported = Inventory.CompilerVisibleProperties.Select(i => i.Name).ToHashSet(StringComparer.Ordinal); + + // Act + var stale = exclusions + .Where(n => !Inventory.IsDefinedOrRead(n) && !exported.Contains(n)) + .Select(n => + $"SdkPropertyClassification excludes '{n}', but no shipped file defines or reads it any more. Remove it from the list." + ) + .ToArray(); + + // Assert + await Assert.That(stale).IsEmpty().Because(string.Join(Environment.NewLine, stale)); + } + + // --------------------------------------------------------------------------------------------- + // Compiler-visible properties + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task CompilerVisibleProperty_EveryItem_HasASchemaEntry() + { + // Arrange + var items = Inventory.CompilerVisibleProperties; + + // Act + var missing = items + .Where(i => !Schema.Properties.ContainsKey(i.Name) && !IsExcluded(i.Name)) + .Select(i => + $"'{i.Name}' is exported as a CompilerVisibleProperty at Sdk/{i.File}:{i.Line} but has no entry in {SchemaFile}. " + + "Add one, so a consumer's analyzer author can find out what build_property." + + i.Name + + " means." + ) + .ToArray(); + + // Assert + await Assert.That(missing).IsEmpty().Because(string.Join(Environment.NewLine, missing)); + } + + [Test] + public async Task CompilerVisibleProperty_EveryItem_IsDefinedOrRead() + { + // Arrange + var items = Inventory.CompilerVisibleProperties; + + // Act + var undefined = items + .Where(i => + !Inventory.IsDefinedOrRead(i.Name) && !SdkPropertyClassification.ExternallyOwned.ContainsKey(i.Name) + ) + .Select(i => + $"'{i.Name}' is exported as a CompilerVisibleProperty at Sdk/{i.File}:{i.Line}, but no shipped file defines or reads it, " + + "so build_property." + + i.Name + + " is always empty. Define it, or remove the CompilerVisibleProperty item." + ) + .ToArray(); + + // Assert + await Assert.That(undefined).IsEmpty().Because(string.Join(Environment.NewLine, undefined)); + } + + [Test] + public async Task BuildPropertyRead_EveryAnalyzerRead_IsCompilerVisible() + { + // Arrange + var sources = AnalyzerProjects + .Select(p => Path.Combine(RepositoryRoot, "src", "src", p)) + .SelectMany(d => Directory.EnumerateFiles(d, "*.cs", SearchOption.AllDirectories)) + .Where(f => + !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + ) + .Where(f => + !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + ); + var exported = Inventory.CompilerVisibleProperties.Select(i => i.Name).ToHashSet(StringComparer.Ordinal); + + // Act + var reads = sources + .SelectMany(f => + BuildPropertyReadPattern() + .Matches(File.ReadAllText(f)) + .Select(m => + (File: Path.GetRelativePath(RepositoryRoot, f).Replace('\\', '/'), m.Groups["name"].Value) + ) + ) + .ToArray(); + var unexported = reads + .Where(r => !exported.Contains(r.Value)) + .Select(r => + $"{r.File} reads build_property.{r.Value}, but Sdk.props exports no CompilerVisibleProperty '{r.Value}', " + + "so the analyzer always sees it unset. Add <CompilerVisibleProperty Include=\"" + + r.Value + + "\"> (with a <Description>) to the compiler-visible ItemGroup in Sdk/Sdk.props." + ) + .ToArray(); + + // Assert + await Assert + .That(reads) + .IsNotEmpty() + .Because("The scan found no build_property reads at all; the pattern is broken."); + await Assert.That(unexported).IsEmpty().Because(string.Join(Environment.NewLine, unexported)); + } + + /// <summary> + /// The static list is what the other tests read, so it must be what MSBuild actually registers. Most + /// items apply to every C# project; the Roslyn compiler settings only to a Roslyn component, so both + /// shapes are evaluated. + /// </summary> + [Test] + public async Task CompilerVisibleProperty_EveryStaticItem_IsRegisteredForSomeCSharpProject( + CancellationToken cancellationToken + ) + { + // Arrange + using var library = await ProjectHarness.CreateAsync("MyLibrary", cancellationToken: cancellationToken); + using var component = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <IsRoslynComponent>true</IsRoslynComponent> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + var declared = Inventory + .CompilerVisibleProperties.Select(i => i.Name) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // Act + var registered = (await library.GetItemIdentitiesAsync("CompilerVisibleProperty", cancellationToken)) + .Concat(await component.GetItemIdentitiesAsync("CompilerVisibleProperty", cancellationToken)) + .ToHashSet(StringComparer.Ordinal); + var missing = declared.Where(n => !registered.Contains(n)).ToArray(); + + // Assert + await Assert + .That(missing) + .IsEmpty() + .Because( + "These CompilerVisibleProperty items are declared in the SDK but registered for neither a class library nor a Roslyn component: " + + string.Join(", ", missing) + ); + } + + // --------------------------------------------------------------------------------------------- + // What the schema claims about each property + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task SchemaDefault_EveryStatedDefault_MatchesTheEvaluatedDefault(CancellationToken cancellationToken) + { + // Arrange + using var h = await ProjectHarness + .For("MyLibrary") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + </Project> + """ + ) + .BuildAsync(cancellationToken); + var stated = Schema + .Properties.Values.Where(p => p.DefaultValue is not null) + .Where(p => !SdkPropertyClassification.SetByTheHarness.ContainsKey(p.Name)) + .ToArray(); + + // Act + var evaluated = await h.GetPropertiesAsync(cancellationToken, [.. stated.Select(p => p.Name)]); + var wrong = stated + .Where(p => + !string.Equals(Normalise(evaluated[p.Name]), Normalise(p.DefaultValue!), StringComparison.Ordinal) + ) + .Select(p => + $"{SchemaFile} states defaultValue '{p.DefaultValue}' for '{p.Name}', but a plain class library evaluates it to " + + $"'{evaluated[p.Name]}'. Update the schema's defaultValue (and the README and wiki rows) to the real default." + ) + .ToArray(); + + // Assert + await Assert.That(wrong).IsEmpty().Because(string.Join(Environment.NewLine, wrong)); + + static string Normalise(string value) => WhitespacePattern().Replace(value.Trim(), ""); + } + + [Test] + public async Task SchemaDefault_EveryEntryWithoutADefault_IsEmptyOrExplained(CancellationToken cancellationToken) + { + // Arrange + using var h = await ProjectHarness + .For("MyLibrary") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + </Project> + """ + ) + .BuildAsync(cancellationToken); + var unstated = Schema.Properties.Values.Where(p => p.DefaultValue is null).ToArray(); + + // Act + var evaluated = await h.GetPropertiesAsync(cancellationToken, [.. unstated.Select(p => p.Name)]); + var unexplained = unstated + .Where(p => + evaluated[p.Name].Trim().Length > 0 && !SdkPropertyClassification.ComputedDefaults.ContainsKey(p.Name) + ) + .Select(p => + $"'{p.Name}' has no defaultValue in {SchemaFile}, but a plain class library evaluates it to '{evaluated[p.Name]}'. " + + "State that as its defaultValue, or - if the value depends on the machine or the project - add it to " + + "SdkPropertyClassification.ComputedDefaults with the reason." + ) + .ToArray(); + var contradicted = Schema + .Properties.Values.Where(p => + p.DefaultValue is not null && SdkPropertyClassification.ComputedDefaults.ContainsKey(p.Name) + ) + .Select(p => + $"'{p.Name}' is listed in SdkPropertyClassification.ComputedDefaults but states a defaultValue. Remove one." + ) + .ToArray(); + + // Assert + await Assert.That(unexplained).IsEmpty().Because(string.Join(Environment.NewLine, unexplained)); + await Assert.That(contradicted).IsEmpty().Because(string.Join(Environment.NewLine, contradicted)); + } + + [Test] + public async Task SchemaDescription_EveryEntryWithoutADefault_SaysWhy() + { + // Arrange + var properties = Schema.Properties.Values; + + // Act + var unexplained = properties + .Where(p => p.DefaultValue is null && !p.Description.Contains(NoDefaultPhrase, StringComparison.Ordinal)) + .Select(p => + $"'{p.Name}' has no defaultValue, so its description in {SchemaFile} must say why, in a sentence starting '{NoDefaultPhrase}'." + ); + var contradicted = properties + .Where(p => p.DefaultValue is not null && p.Description.Contains(NoDefaultPhrase, StringComparison.Ordinal)) + .Select(p => + $"'{p.Name}' states defaultValue '{p.DefaultValue}' and also says '{NoDefaultPhrase}'. Remove one." + ); + var problems = unexplained.Concat(contradicted).ToArray(); + + // Assert + await Assert.That(problems).IsEmpty().Because(string.Join(Environment.NewLine, problems)); + } + + [Test] + public async Task SchemaDescription_PreImportClaim_MatchesTheSdkPropsReads() + { + // Arrange + var properties = Schema.Properties.Values; + + // Act + var unclaimed = properties + .Where(p => + Inventory.PropsReads.ContainsKey(p.Name) + && !p.Description.Contains(PreImportPhrase, StringComparison.Ordinal) + ) + .Select(p => + $"'{p.Name}' is read while Sdk.props is evaluated (Sdk/{Inventory.PropsReads[p.Name]}), so a value set in a project " + + $"file comes too late. Say so in its description in {SchemaFile}: \"Set it {PreImportPhrase}.\"" + ); + var overclaimed = properties + .Where(p => + !Inventory.PropsReads.ContainsKey(p.Name) + && p.Description.Contains(PreImportPhrase, StringComparison.Ordinal) + ) + .Select(p => + $"The description of '{p.Name}' in {SchemaFile} says to set it {PreImportPhrase}, but nothing in Sdk.props or the " + + "files it imports reads it, so a project file can set it too. Remove the claim." + ); + var problems = unclaimed.Concat(overclaimed).ToArray(); + + // Assert + await Assert.That(problems).IsEmpty().Because(string.Join(Environment.NewLine, problems)); + } + + [Test] + public async Task SchemaType_EveryBooleanDefault_IsTrueOrFalse() + { + // Arrange + var booleans = Schema.Properties.Values.Where(p => p.IntrinsicType == "bool" && p.DefaultValue is not null); + + // Act + var invalid = booleans + .Where(p => p.DefaultValue is not ("true" or "false")) + .Select(p => $"'{p.Name}' is typed bool in {SchemaFile} but its defaultValue is '{p.DefaultValue}'.") + .ToArray(); + + // Assert + await Assert.That(invalid).IsEmpty().Because(string.Join(Environment.NewLine, invalid)); + } + + [Test] + public async Task SchemaType_EveryEnumeration_IsRecognisedByTheSdk() + { + // Arrange + var shipped = string.Join( + "\n", + Directory + .EnumerateFiles(SdkPaths.SdkDirectory, "*.*", SearchOption.AllDirectories) + .Where(f => + f.EndsWith(".props", StringComparison.Ordinal) || f.EndsWith(".targets", StringComparison.Ordinal) + ) + .Select(File.ReadAllText) + ); + var enumerations = Schema.Properties.Values.Where(p => p.Values.Count > 0 && !p.AllowUnknownValues).ToArray(); + + // Act + var unrecognised = enumerations + .SelectMany(p => p.Values.Select(v => (Property: p, Value: v))) + .Where(x => !shipped.Contains(x.Value, StringComparison.OrdinalIgnoreCase)) + .Select(x => + $"{SchemaFile} offers '{x.Value}' for '{x.Property.Name}', but no shipped file mentions that value, so the SDK cannot act on it." + ); + var defaultsOutsideTheList = enumerations + .Where(p => + p.DefaultValue is not null && !p.Values.Contains(p.DefaultValue, StringComparer.OrdinalIgnoreCase) + ) + .Select(p => $"'{p.Name}' defaults to '{p.DefaultValue}', which is not one of its values in {SchemaFile}."); + var problems = unrecognised.Concat(defaultsOutsideTheList).ToArray(); + + // Assert + await Assert.That(enumerations).IsNotEmpty(); + await Assert.That(problems).IsEmpty().Because(string.Join(Environment.NewLine, problems)); + } + + // --------------------------------------------------------------------------------------------- + // The meta-schema + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Schema_ValidatesAgainstTheMetaSchema() + { + // Arrange + var validator = new DraftFourValidator(LoadMetaSchema()); + + // Act + var errors = validator.Validate(Schema.Root); + + // Assert + await Assert.That(errors).IsEmpty().Because(string.Join(Environment.NewLine, errors)); + } + + [Test] + public async Task Schema_DeclaresTheMetaSchema() + { + // Arrange + var root = Schema.Root; + + // Act + var declared = root.TryGetProperty("$schema", out var value) ? value.GetString() : null; + + // Assert + await Assert + .That(declared) + .IsEqualTo( + "https://github.com/mhutch/MonoDevelop.MSBuildEditor/raw/main/MonoDevelop.MSBuild/Schemas/buildschema.json" + ); + } + + [Test] + public async Task MetaSchema_DeclaresDraftFour() + { + // Arrange + var metaSchema = LoadMetaSchema(); + + // Act + var draft = metaSchema.GetProperty("$schema").GetString(); + + // Assert + await Assert.That(draft).IsEqualTo("http://json-schema.org/draft-04/schema#"); + } + + [Test] + public async Task MetaSchemaValidator_ImplementsEveryKeywordTheMetaSchemaUses() + { + // Arrange + var metaSchema = LoadMetaSchema(); + + // Act + var unsupported = DraftFourValidator.UnsupportedKeywords(metaSchema); + + // Assert + await Assert + .That(unsupported) + .IsEmpty() + .Because( + "The vendored meta-schema uses keywords DraftFourValidator does not implement, so validation would pass without checking them: " + + string.Join(", ", unsupported) + ); + } + + [Test] + public async Task MetaSchemaValidator_InvalidSchema_IsRejected() + { + // Arrange + var validator = new DraftFourValidator(LoadMetaSchema()); + using var invalid = JsonDocument.Parse( + """ + { "properties": { "Foo": { "description": "x", "type": "not-a-type", "unknownKey": true } } } + """ + ); + + // Act + var errors = validator.Validate(invalid.RootElement); + + // Assert + await Assert.That(errors).IsNotEmpty(); + } + + static JsonElement LoadMetaSchema() + { + var path = Path.Combine( + RepositoryRoot, + "src", + "tests", + "BuildSdk.IntegrationTests", + "Schemas", + "buildschema.json" + ); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + return document.RootElement.Clone(); + } + + // --------------------------------------------------------------------------------------------- + // README and wiki tables + // --------------------------------------------------------------------------------------------- + + /// <summary>Every markdown file whose property tables are held to the schema.</summary> + public static IEnumerable<string> DocumentsWithPropertyTables() => + [ + "README.md", + .. Directory + .EnumerateFiles(Path.Combine(RepositoryRoot, "docs", "wiki"), "*.md", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(RepositoryRoot, f).Replace('\\', '/')) + .Order(StringComparer.Ordinal), + ]; + + /// <summary>The documents that must list every property: the README and the wiki's reference page.</summary> + public static IEnumerable<string> ReferenceDocuments() => ["README.md", "docs/wiki/Configuration-Reference.md"]; + + static IReadOnlyList<MarkdownPropertyRow> Rows(string document) => + MarkdownPropertyTables.Read(Path.Combine(RepositoryRoot, document), document); + + [Test] + [MethodDataSource(nameof(ReferenceDocuments))] + public async Task Documentation_ConfigurationTables_ListEverySchemaProperty(string document) + { + // Arrange + var documented = Rows(document) + .Where(r => r.DefaultCell is not null) + .Select(r => r.Name) + .ToHashSet(StringComparer.Ordinal); + + // Act + var missing = Schema + .Properties.Keys.Where(n => !documented.Contains(n)) + .Order(StringComparer.Ordinal) + .Select(n => + $"{document} has no configuration-table row for '{n}'. Add `| `{n}` | <default> | <description> |` to the table for its area " + + $"(a table with a Default column), using the defaultValue from {SchemaFile}." + ) + .ToArray(); + + // Assert + await Assert.That(missing).IsEmpty().Because(string.Join(Environment.NewLine, missing)); + } + + [Test] + [MethodDataSource(nameof(DocumentsWithPropertyTables))] + public async Task Documentation_EveryStatedDefault_MatchesTheSchema(string document) + { + // Arrange + var rows = Rows(document).Where(r => r.DefaultCell is not null && Schema.Properties.ContainsKey(r.Name)); + + // Act + var wrong = rows.Select(r => + (Row: r, Stated: StatedLiteral(r.DefaultCell), Schema: Schema.Properties[r.Name].DefaultValue) + ) + .Where(x => !Agrees(x.Stated, x.Schema)) + .Select(x => + x.Schema is null + ? $"{x.Row.File}:{x.Row.Line} states default `{x.Stated}` for '{x.Row.Name}', but {SchemaFile} gives it no default. " + + "Describe it in italics instead, for example *(detected)*, or add the defaultValue to the schema." + : $"{x.Row.File}:{x.Row.Line} states default {(x.Stated is null ? x.Row.DefaultCell : $"`{x.Stated}`")} for '{x.Row.Name}', " + + $"but {SchemaFile} says `{x.Schema}`. Make the row open with `{MarkdownPropertyTables.NormaliseList(x.Schema)}`." + ) + .ToArray(); + + // Assert + await Assert.That(wrong).IsEmpty().Because(string.Join(Environment.NewLine, wrong)); + + static string? StatedLiteral(string? cell) => + MarkdownPropertyTables.StatedDefault(cell) is { } literal + && !literal.Contains('<', StringComparison.Ordinal) + ? literal + : null; + + static bool Agrees(string? stated, string? schema) => + (stated, schema) switch + { + (null, null) => true, + (null, _) or (_, null) => false, + _ => string.Equals( + MarkdownPropertyTables.NormaliseList(stated), + MarkdownPropertyTables.NormaliseList(schema), + StringComparison.Ordinal + ), + }; + } + + [Test] + [MethodDataSource(nameof(DocumentsWithPropertyTables))] + public async Task Documentation_EveryPropertyRow_NamesARealProperty(string document) + { + // Arrange + var rows = Rows(document); + + // Act + // A '_'-prefixed name is private by convention; a page explaining internals may still list one. + var unknown = rows.Where(r => + r.Name[0] != '_' + && !Schema.Properties.ContainsKey(r.Name) + && !IsExcluded(r.Name) + && !SdkPropertyClassification.DocumentedNonProperties.ContainsKey(r.Name) + ) + .Select(r => + $"{r.File}:{r.Line} documents '{r.Name}', which is neither in {SchemaFile} nor a known external property. " + + "Fix the name, remove the row, or add the property to the schema." + ) + .ToArray(); + + // Assert + await Assert.That(unknown).IsEmpty().Because(string.Join(Environment.NewLine, unknown)); + } + + [Test] + [MethodDataSource(nameof(ReferenceDocuments))] + public async Task Documentation_CompilerVisibleTable_ListsEveryCompilerVisibleProperty(string document) + { + // Arrange + var table = Rows(document) + .Where(r => r.Section == MarkdownPropertyTables.CompilerVisibleSection) + .Select(r => r.Name) + .ToHashSet(StringComparer.Ordinal); + var exported = Inventory.CompilerVisibleProperties.Select(i => i.Name).ToHashSet(StringComparer.Ordinal); + + // Act + var missing = exported + .Where(n => !table.Contains(n)) + .Order(StringComparer.Ordinal) + .Select(n => + $"{document} does not list the compiler-visible property '{n}'. Add `| `{n}` | <description> |` to its " + + $"'{MarkdownPropertyTables.CompilerVisibleSection}' table." + ); + var extra = table + .Where(n => !exported.Contains(n)) + .Order(StringComparer.Ordinal) + .Select(n => + $"{document} lists '{n}' as compiler-visible, but Sdk.props exports no CompilerVisibleProperty '{n}'. Remove the row." + ); + var problems = missing.Concat(extra).ToArray(); + + // Assert + await Assert.That(table).IsNotEmpty(); + await Assert.That(problems).IsEmpty().Because(string.Join(Environment.NewLine, problems)); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PropertySchemaValidationTargetTests.cs b/src/tests/BuildSdk.IntegrationTests/PropertySchemaValidationTargetTests.cs new file mode 100644 index 0000000..219034a --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/PropertySchemaValidationTargetTests.cs @@ -0,0 +1,303 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; +using Purview.BuildSdk.PropertySchema; + +namespace Purview.BuildSdk; + +/// <summary> +/// Verifies <c>ValidatePurviewPropertySchema</c>, the opt-in check another package runs to keep its +/// <c>.buildschema.json</c> sidecars and its shipped <c>.props</c>/<c>.targets</c> in step. +/// </summary> +sealed class PropertySchemaValidationTargetTests +{ + const string KitProps = """ + <Project> + <PropertyGroup> + <AcmeKitEnabled Condition="'$(AcmeKitEnabled)' == ''">true</AcmeKitEnabled> + </PropertyGroup> + <ItemGroup> + <CompilerVisibleProperty Include="AcmeKitEnabled" /> + </ItemGroup> + </Project> + """; + + const string KitSchema = """ + { + "properties": { + "AcmeKitEnabled": { "description": "Turns the kit on.", "type": "bool", "defaultValue": "true" } + } + } + """; + + static async Task<ProjectHarness> CreatePackageAsync( + IReadOnlyDictionary<string, string> files, + CancellationToken cancellationToken, + string? preImportProperties = null + ) + { + // The fixture's build/ files are not named for the package id, which NuGet reports as NU5129. That is + // not what these tests are about, so the warning must not fail the pack. + var builder = ProjectHarness + .For("Kit") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + </Project> + """ + ); + if (preImportProperties is not null) + builder.WithPreImportPropertiesRaw(preImportProperties); + + var h = await builder.BuildAsync(cancellationToken); + foreach (var (relativePath, content) in files) + { + var path = Path.Combine(h.ProjectDirectory, "Sdk", relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content, cancellationToken); + } + + return h; + } + + [Test] + public async Task ValidatePurviewPropertySchema_SidecarAgreesWithTheProps_Succeeds( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.props.buildschema.json"] = KitSchema, + }, + cancellationToken + ); + + // Act + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut + stdErr).DoesNotContain("PRPSC"); + } + + [Test] + public async Task ValidatePurviewPropertySchema_CompilerVisiblePropertyWithoutAnEntry_ReportsPRPSC0001( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps.Replace( + "<CompilerVisibleProperty Include=\"AcmeKitEnabled\" />", + "<CompilerVisibleProperty Include=\"AcmeKitEnabled\" /><CompilerVisibleProperty Include=\"AcmeKitMode\" />", + StringComparison.Ordinal + ), + ["buildTransitive/Acme.Kit.props.buildschema.json"] = KitSchema, + }, + cancellationToken + ); + + // Act + var (exitCode, stdOut, _) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(stdOut).Contains("PRPSC0001"); + await Assert.That(stdOut).Contains("'AcmeKitMode' is exported as a CompilerVisibleProperty"); + await Assert.That(stdOut).Contains("Acme.Kit.props.buildschema.json"); + } + + [Test] + public async Task ValidatePurviewPropertySchema_EntryNothingDefines_ReportsPRPSC0002( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.props.buildschema.json"] = KitSchema.Replace( + "\"AcmeKitEnabled\":", + "\"AcmeKitRemoved\": \"Gone.\", \"AcmeKitEnabled\":", + StringComparison.Ordinal + ), + }, + cancellationToken + ); + + // Act + var (exitCode, stdOut, _) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(stdOut).Contains("PRPSC0002"); + await Assert.That(stdOut).Contains("'AcmeKitRemoved'"); + } + + [Test] + [Arguments("{ \"properties\": { \"AcmeKitEnabled\": ")] + [Arguments("{ \"types\": {} }")] + [Arguments("[]")] + public async Task ValidatePurviewPropertySchema_UnreadableSidecar_ReportsPRPSC0003( + string sidecar, + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.props.buildschema.json"] = sidecar, + }, + cancellationToken + ); + + // Act + var (exitCode, stdOut, _) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(stdOut).Contains("PRPSC0003"); + } + + /// <summary> + /// The most likely placement mistake: naming the sidecar after the package rather than after the + /// file it describes, or dropping the file's extension, so MSBuild Editor never finds it. + /// </summary> + [Test] + public async Task ValidatePurviewPropertySchema_SidecarWithoutItsFile_ReportsPRPSC0004( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.buildschema.json"] = KitSchema, + }, + cancellationToken + ); + + // Act + var (exitCode, stdOut, _) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(stdOut).Contains("PRPSC0004"); + } + + [Test] + public async Task ValidatePurviewPropertySchema_IgnoredExport_IsNotReported(CancellationToken cancellationToken) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps.Replace( + "<CompilerVisibleProperty Include=\"AcmeKitEnabled\" />", + "<CompilerVisibleProperty Include=\"AcmeKitEnabled\" /><CompilerVisibleProperty Include=\"RootNamespace\" />", + StringComparison.Ordinal + ), + ["buildTransitive/Acme.Kit.props.buildschema.json"] = KitSchema, + }, + cancellationToken, + preImportProperties: "<PurviewPropertySchemaIgnoredProperties>RootNamespace</PurviewPropertySchemaIgnoredProperties>" + ); + + // Act + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-t:ValidatePurviewPropertySchema", cancellationToken); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + } + + /// <summary> + /// Dogfoods the target on Purview.BuildSdk's own Sdk/ folder, ignoring the exports MSBuild and the .NET + /// SDK own - the same exclusions PropertySchemaTests applies. The two checks therefore cannot drift. + /// </summary> + [Test] + public async Task ValidatePurviewPropertySchema_ThisSdk_Succeeds(CancellationToken cancellationToken) + { + // Arrange + using var h = await ProjectHarness.CreateAsync("Kit", cancellationToken: cancellationToken); + var ignored = string.Join("%3B", SdkPropertyClassification.ExternallyOwned.Keys); + var sdkDirectory = SdkPaths.SdkDirectory; + + // Act + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync( + $"-t:ValidatePurviewPropertySchema \"-p:PurviewPropertySchemaDirectory={sdkDirectory}\" \"-p:PurviewPropertySchemaIgnoredProperties={ignored}\"", + cancellationToken + ); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut + stdErr).DoesNotContain("PRPSC"); + } + + /// <summary> + /// The guarantee every consumer relies on: a package with a broken sidecar still builds and packs + /// exactly as before unless it opted in. + /// </summary> + [Test] + public async Task Pack_WithoutTheOptIn_DoesNotValidateTheSchema(CancellationToken cancellationToken) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.buildschema.json"] = "not json", + }, + cancellationToken, + preImportProperties: """ + <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> + <DisableSourceLink>true</DisableSourceLink> + <IsPackable>true</IsPackable> + """ + ); + + // Act + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-restore -t:Build;Pack", cancellationToken); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut + stdErr).DoesNotContain("PRPSC"); + } + + [Test] + public async Task Pack_WithTheOptIn_FailsOnABrokenSidecar(CancellationToken cancellationToken) + { + // Arrange + using var h = await CreatePackageAsync( + new Dictionary<string, string> + { + ["buildTransitive/Acme.Kit.props"] = KitProps, + ["buildTransitive/Acme.Kit.buildschema.json"] = KitSchema, + }, + cancellationToken, + preImportProperties: """ + <EnablePurviewPropertySchemaValidation>true</EnablePurviewPropertySchemaValidation> + <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> + <DisableSourceLink>true</DisableSourceLink> + <IsPackable>true</IsPackable> + """ + ); + + // Act + var (exitCode, stdOut, _) = await h.RunMSBuildAsync("-restore -t:Pack", cancellationToken); + + // Assert + await Assert.That(exitCode).IsNotEqualTo(0); + await Assert.That(stdOut).Contains("PRPSC0004"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillInstallCommandTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillInstallCommandTests.cs index 9e3d54a..36a72c8 100644 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillInstallCommandTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PurviewSkillInstallCommandTests.cs @@ -4,103 +4,93 @@ namespace Purview.BuildSdk; -// CA1308: NuGet's global-packages layout is lowercase on disk and in the assets file's "path" value, -// so a fake restored package has to be written to the lowercase folder to be found at all. This is -// not normalisation for comparison. -#pragma warning disable CA1308 - /// <summary> -/// Verifies the command <c>InstallPurviewSkills</c> hands to the skills CLI. The CLI does not split a -/// comma-separated <c>--skill</c> value - it matches nothing and reports "No matching skills found" - -/// so the shape of this command is a correctness concern, not a formatting one. +/// Verifies the command <c>InstallPurviewSkills</c> hands to the skills CLI, and the repository root it +/// runs from. The target is a thin wrapper: <c>skills add <source></c> for an explicit +/// <c>PurviewSkillsSource</c>, or <c>skills experimental_install</c> to restore <c>skills-lock.json</c>. /// </summary> +/// <remarks> +/// <c>echo</c> stands in for the CLI so a test asserts on the command without needing Node, a network, +/// or a real install. Exec runs through the platform shell, and echo is a builtin of both cmd and sh, +/// so this is identical on Windows, Linux and macOS. The throwaway solution is not a git repository and +/// has no <c>AGENTS.md</c>, so the repository root is named explicitly where a test needs one. +/// </remarks> sealed class PurviewSkillInstallCommandTests { - static void WriteFakePackage(string packagesRoot, string packageId, string version, string[] skillNames) + [Test] + public async Task InstallPurviewSkills_FromASource_AddsEverySkillItHas(CancellationToken cancellationToken) { - var packageRoot = Path.Combine(packagesRoot, packageId.ToLowerInvariant(), version); - Directory.CreateDirectory(packageRoot); - - foreach (var skillName in skillNames) - { - var skillDirectory = Path.Combine(packageRoot, ".agents", "skills", skillName); - Directory.CreateDirectory(skillDirectory); - File.WriteAllText( - Path.Combine(skillDirectory, "SKILL.md"), - $"---\nname: {skillName}\ndescription: Use when exercising skill installation.\n---\n" - ); - } - - File.WriteAllText( - Path.Combine(packageRoot, $"{packageId.ToLowerInvariant()}.nuspec"), - $""" - <?xml version="1.0" encoding="utf-8"?> - <package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd"> - <metadata> - <id>{packageId}</id> - <version>{version}</version> - <repository type="git" url="https://github.com/purview-dev/purview-example/" commit="0123456789abcdef0123456789abcdef01234567" /> - </metadata> - </package> - """ - ); - } + using var harness = await ProjectHarness.CreateAsync("SkillInstaller", cancellationToken: cancellationToken); - static void WriteAssetsFile(string assetsPath, string packageId, string version) - { - Directory.CreateDirectory(Path.GetDirectoryName(assetsPath)!); - File.WriteAllText( - assetsPath, - $$""" - { - "version": 3, - "libraries": { - "{{packageId}}/{{version}}": { - "sha512": "", - "type": "package", - "path": "{{packageId.ToLowerInvariant()}}/{{version}}", - "files": [] - } - } - } - """ + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + "-t:InstallPurviewSkills -v:n " + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " + + "-p:PurviewSkillsSource=purview-dev/engineering-playbook#abc123 " + + "-p:PurviewSkillsCommand=echo -p:PurviewSkillsAgents=claude-code", + cancellationToken ); + + await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + // The caller's source and ref verbatim, every skill it has, then the agent selection. + await Assert + .That(stdOut) + .Contains("add \"purview-dev/engineering-playbook#abc123\" --skill \"*\" --agent \"claude-code\" -y"); } [Test] - public async Task InstallPurviewSkills_RepeatsTheSkillFlagPerSkill(CancellationToken cancellationToken) + public async Task InstallPurviewSkills_WithoutAgents_LetsTheCliAutoDetect(CancellationToken cancellationToken) { using var harness = await ProjectHarness.CreateAsync("SkillInstaller", cancellationToken: cancellationToken); - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha", "purview-beta"]); + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + "-t:InstallPurviewSkills -v:n " + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " + + "-p:PurviewSkillsSource=purview-dev/build-sdk -p:PurviewSkillsCommand=echo", + cancellationToken + ); + + await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut).Contains("--skill \"*\" -y"); + await Assert.That(stdOut).DoesNotContain("--agent"); + } - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, "Purview.Example", "1.2.3"); + [Test] + public async Task InstallPurviewSkills_WithoutASource_RestoresTheLockFile(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness.CreateAsync("SkillRestorer", cancellationToken: cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(harness.SolutionDirectory, "skills-lock.json"), + /*lang=json,strict*/"""{"version": 1, "skills": {}}""", + cancellationToken + ); - // 'echo' stands in for the CLI so the test asserts on the command without needing Node, a - // network, or a real install. Exec runs through the platform shell, and echo is a builtin of - // both cmd and sh, so this is identical on Windows, Linux and macOS. - // The throwaway solution is not a git repository and has no AGENTS.md, so the repository root - // is named explicitly - the CLI always runs from a repository root and never falls back to - // the project folder. var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( "-t:InstallPurviewSkills -v:n " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\" " - + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " - + "-p:PurviewSkillsCommand=echo -p:PurviewSkillsAgents=claude-code", + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" -p:PurviewSkillsCommand=echo", cancellationToken ); + // A fresh clone gets its skills back the standard way: the CLI restores what the lockfile records. await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut).Contains("experimental_install"); + await Assert.That(stdOut).DoesNotContain(" add "); + } + + [Test] + public async Task InstallPurviewSkills_WithoutASourceOrALockFile_SaysWhatToDo(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness.CreateAsync("SkillNothing", cancellationToken: cancellationToken); - // The pinned source, then one --skill flag per resolved skill, then the agent selection. - await Assert.That(stdOut).Contains("purview-dev/purview-example#0123456789abcdef0123456789abcdef01234567"); - await Assert.That(stdOut).Contains("--skill \"purview-alpha\" --skill \"purview-beta\""); - await Assert.That(stdOut).Contains("--agent \"claude-code\""); - await Assert.That(stdOut).Contains("-y"); - // The comma form is the bug this test exists to prevent. - await Assert.That(stdOut).DoesNotContain("purview-alpha,purview-beta"); + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + "-t:InstallPurviewSkills -v:n " + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" -p:PurviewSkillsCommand=echo", + cancellationToken + ); + + await Assert.That(exitCode).IsNotEqualTo(0); + var output = stdOut + stdErr; + await Assert.That(output).Contains("PRSKL0007"); + await Assert.That(output).Contains("PurviewSkillsSource"); } /// <summary> @@ -109,33 +99,37 @@ public async Task InstallPurviewSkills_RepeatsTheSkillFlagPerSkill(CancellationT /// mapping is not a formatting detail: it is a command that does not run. /// </summary> [Test] - [Arguments("npx", "npx skills")] - [Arguments("bunx", "bunx skills")] - [Arguments("bun", "bun x skills")] - [Arguments("pnpm", "pnpm dlx skills")] - [Arguments("yarn", "yarn dlx skills")] - [Arguments("deno", "deno run -A npm:skills")] + [Arguments("npx", "npx -y skills@1")] + [Arguments("bunx", "bunx skills@1")] + [Arguments("bun", "bun x skills@1")] + [Arguments("pnpm", "pnpm dlx skills@1")] + [Arguments("yarn", "yarn dlx skills@1")] + [Arguments("deno", "deno run -A npm:skills@1")] [Arguments("skills", "skills")] // A runner this SDK has never heard of still resolves, rather than being silently dropped. - [Arguments("future-runner", "future-runner skills")] - public async Task SkillsRunner_DrivesTheRecommendedCommand( + [Arguments("future-runner", "future-runner skills@1")] + public async Task SkillsRunner_DrivesTheExecutedCommand( string runner, - string expectedPrefix, + string expectedLauncher, CancellationToken cancellationToken ) { - using var harness = await ProjectHarness.CreateAsync("SkillRunner", cancellationToken: cancellationToken); + // An empty PATH means no runner is found, so the dry run falls back to the configured one. + using var harness = await ProjectHarness.CreateAsync( + "SkillRunner", + extraEnv: new Dictionary<string, string> { ["PATH"] = string.Empty }, + cancellationToken: cancellationToken + ); var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - "-t:_ResolvePurviewSkillRecommendationPaths " - + "-getProperty:_PurviewSkillsCommandDisplay " - + $"-p:PurviewSkillsRunner={runner}", + "-t:InstallPurviewSkills -v:n " + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " + + $"-p:PurviewSkillsSource=purview-dev/build-sdk -p:PurviewSkillsDryRun=true -p:PurviewSkillsRunner={runner}", cancellationToken ); await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); - // Recommendation text is the command a developer copies, so it carries no version pin. - await Assert.That(stdOut.Trim()).IsEqualTo(expectedPrefix); + await Assert.That(stdOut).Contains($"Agent skills (dry run): {expectedLauncher} add \"purview-dev/build-sdk\""); } [Test] @@ -152,30 +146,6 @@ public async Task SkillsRunner_DefaultsToBun(CancellationToken cancellationToken await Assert.That(stdOut.Trim()).IsEqualTo("bun"); } - [Test] - public async Task InstallPurviewSkills_WithoutAgents_LetsTheCliAutoDetect(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillInstaller", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, "Purview.Example", "1.2.3"); - - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - "-t:InstallPurviewSkills -v:n " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\" " - + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " - + "-p:PurviewSkillsCommand=echo", - cancellationToken - ); - - await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); - await Assert.That(stdOut).Contains("--skill \"purview-alpha\" -y"); - await Assert.That(stdOut).DoesNotContain("--agent"); - } - /// <summary> /// The executed launcher pins the CLI to a major, so a breaking release cannot change what /// <c>InstallPurviewSkills</c> does. Each runner's exact invocation was verified against the real @@ -228,15 +198,9 @@ public async Task InstallPurviewSkills_WithoutACliOnPath_ReportsHowToInstallOne( cancellationToken: cancellationToken ); - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, "Purview.Example", "1.2.3"); - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( "-t:InstallPurviewSkills -v:n " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\"", + + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" -p:PurviewSkillsSource=purview-dev/build-sdk", cancellationToken ); @@ -245,62 +209,10 @@ public async Task InstallPurviewSkills_WithoutACliOnPath_ReportsHowToInstallOne( var output = stdOut + stdErr; await Assert.That(output).Contains("PRSKL0004"); await Assert.That(output).Contains("npm install -g skills"); - await Assert.That(output).Contains("ListPurviewSkills"); - } - - [Test] - public async Task ListPurviewSkills_ReportsCliAvailabilityWithoutNeedingIt(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync( - "SkillListNoCli", - extraEnv: new Dictionary<string, string> { ["PATH"] = string.Empty }, - cancellationToken: cancellationToken - ); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, "Purview.Example", "1.2.3"); - - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - "-t:ListPurviewSkills -v:n " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\"", - cancellationToken - ); - - // Listing resolves and reports with no Node tooling present - it must not fail. - await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); - await Assert.That(stdOut).Contains("Skills CLI: no runner found on PATH"); - await Assert.That(stdOut).Contains("purview-dev/purview-example"); - } - - [Test] - public async Task InstallPurviewSkills_WithExplicitSource_NeedsNoRestore(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync( - "SkillExplicitSource", - cancellationToken: cancellationToken - ); - - // No ProjectAssetsFile is written: an explicit source does not come from the restore graph, - // so PRSKL0001 must not fire. - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - "-t:InstallPurviewSkills -v:n " - + "-p:ProjectAssetsFile=\"does-not-exist.json\" " - + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" " - + "-p:PurviewSkillsSource=purview-dev/engineering-playbook#abc123 " - + "-p:PurviewSkillsCommand=echo -p:PurviewSkillsAgents=claude-code", - cancellationToken - ); - - await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); - await Assert.That(stdOut + stdErr).DoesNotContain("PRSKL0001"); - await Assert.That(stdOut).Contains("add \"purview-dev/engineering-playbook#abc123\""); } /// <summary> - /// Resolves the repository root the install and recommendation targets run from, by asking + /// Resolves the repository root every skill target runs from, by asking /// MSBuild for the property the shared resolution target computes. /// </summary> static async Task<string> ResolveRepositoryRootAsync( @@ -538,16 +450,8 @@ CancellationToken cancellationToken cancellationToken: cancellationToken ); - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, "Purview.Example", "1.2.3"); - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - "-t:InstallPurviewSkills -v:n " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\" " - + "-p:PurviewSkillsCommand=echo", + "-t:InstallPurviewSkills -v:n -p:PurviewSkillsSource=purview-dev/build-sdk -p:PurviewSkillsCommand=echo", cancellationToken ); @@ -558,5 +462,3 @@ CancellationToken cancellationToken await Assert.That(output).Contains("PurviewSkillsInstallRoot"); } } - -#pragma warning restore CA1308 diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillRecommendationTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillRecommendationTests.cs deleted file mode 100644 index d6a7dca..0000000 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillRecommendationTests.cs +++ /dev/null @@ -1,447 +0,0 @@ -using Purview.BuildSdk.Harness; -using Purview.BuildSdk.Infra; - -namespace Purview.BuildSdk; - -/// <summary> -/// Verifies the recommendation half of the skills integration: what the SDK says about the skills a -/// referenced package ships versus the skills the repository has installed, and - just as -/// importantly - when it says nothing at all. Every scenario here is offline and spawns no process: -/// the inputs are the restore graph and the skills CLI's own <c>skills-lock.json</c>, both written -/// directly by the test. -/// </summary> -sealed class PurviewSkillRecommendationTests -{ - const string PackageId = "Purview.Example"; - const string PackageVersion = "1.2.3"; - const string Source = "purview-dev/purview-example"; - const string OtherCommit = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee"; - - /// <summary> - /// Drives a recommendation target against a fabricated restore graph. The assets file and package - /// folder are supplied explicitly so no real restore is needed, and the repository root is pinned - /// to the harness solution so the lockfile stays inside the throwaway tree. - /// </summary> - /// <remarks> - /// <c>BuildingProject</c> is supplied because MSBuild only sets it when the <c>Build</c> target - /// actually runs; invoking a target by name leaves it <c>false</c>. These targets no longer gate - /// on it, but supplying it keeps the invocation shaped like a real build. - /// </remarks> - static async Task<(int Code, string Output)> RecommendAsync( - ProjectHarness harness, - string target = "RecommendPurviewSkills", - string extraArguments = "", - CancellationToken cancellationToken = default - ) - { - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - - var (code, stdOut, stdErr) = await harness.RunMSBuildAsync( - $"-v:m -t:{target} -p:BuildingProject=true " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\" " - + $"-p:PurviewSkillsRepoRoot=\"{harness.SolutionDirectory}\" {extraArguments}", - cancellationToken - ); - - return (code, stdOut + stdErr); - } - - /// <summary>Writes the default one-package restore graph used by most scenarios here.</summary> - static void GivenPackageShipping(ProjectHarness harness, params string[] skillNames) - { - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - SkillPackageFixtures.WritePackage(packagesRoot, PackageId, PackageVersion, skillNames); - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"), - (PackageId, PackageVersion) - ); - } - - static string LockFilePath(ProjectHarness harness) => Path.Combine(harness.SolutionDirectory, "skills-lock.json"); - - /// <summary> - /// Stands in for a repository whose lockfile has actually been restored. The SDK looks for any - /// dot-folder at the repository root holding a <c>skills</c> folder rather than hard-coding the - /// agent directories, which belong to the CLI. - /// </summary> - static void GivenSkillsRestored(ProjectHarness harness) => - Directory.CreateDirectory(Path.Combine(harness.SolutionDirectory, ".claude", "skills")); - - [Test] - public async Task MissingSkills_AreReportedWithAPinnedInstallCommand(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha", "purview-beta"); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - // A missing skill is advice, not a defect: it must never fail the build. - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0010"); - await Assert.That(output).Contains("purview-alpha"); - await Assert.That(output).Contains("purview-beta"); - // The ref is the commit the restored package was built from, and each skill is named with its - // own flag because the CLI matches nothing when given one comma-separated value. - await Assert - .That(output) - .Contains( - $"bun x skills add \"{Source}#{SkillPackageFixtures.DefaultCommit}\" " - + "--skill \"purview-alpha\" --skill \"purview-beta\"" - ); - } - - [Test] - public async Task SkillsInstalledAtTheReferencedCommit_ReportNothing(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - // A correctly installed set is the silent case: no diagnostic of any kind. - await Assert.That(output).DoesNotContain("PRSKL"); - } - - [Test] - public async Task DriftedSkills_RecommendAPinnedInstallRatherThanAnUpdate(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - // Installed from the right repository, but from some other commit: the installed skill may - // describe a different version of the library than the one this project references. - SkillPackageFixtures.WriteLockFile(LockFilePath(harness), ("purview-alpha", Source, OtherCommit)); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0011"); - await Assert.That(output).Contains(OtherCommit); - // The recommendation is pinned to the referenced package's own commit. 'bun x skills update' - // installs the newest published skill regardless of package version, so it is named only to - // explain why it is not the recommendation. - await Assert.That(output).Contains($"bun x skills add \"{Source}#{SkillPackageFixtures.DefaultCommit}\""); - await Assert.That(output).Contains("Prefer this over 'bun x skills update'"); - await Assert.That(output).DoesNotContain("PRSKL0010"); - } - - [Test] - public async Task SkillsInstalledWithoutARef_AreReportedAsUnverifiable(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - // An unpinned install records no ref, so there is nothing to compare the package commit to. - SkillPackageFixtures.WriteLockFile(LockFilePath(harness), ("purview-alpha", Source, null)); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0012"); - await Assert.That(output).Contains("bun x skills list"); - // Unverifiable is not the same as wrong, so it is never reported as drift or as missing. - await Assert.That(output).DoesNotContain("PRSKL0011"); - await Assert.That(output).DoesNotContain("PRSKL0010"); - } - - [Test] - public async Task PackagesThatCannotBePinned_AreReportedAsUnverifiable(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - // No repository metadata at all, so no source and no commit can be derived. - SkillPackageFixtures.WritePackage(packagesRoot, PackageId, PackageVersion, ["purview-alpha"], null, null); - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"), - (PackageId, PackageVersion) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0012"); - // Never recommended from a guessed default branch. - await Assert.That(output).DoesNotContain("PRSKL0010"); - } - - [Test] - public async Task SkillsFromAnUnreferencedRepositoryOfTheSameOwner_AreReportedAsOrphaned( - CancellationToken cancellationToken - ) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit), - ("purview-legacy", "purview-dev/retired-library", SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0013"); - await Assert.That(output).Contains("purview-legacy"); - await Assert.That(output).Contains("bun x skills remove \"purview-legacy\""); - // Removal is the developer's, so it is only ever suggested. - await Assert - .That(await File.ReadAllTextAsync(LockFilePath(harness), cancellationToken)) - .Contains("purview-legacy"); - } - - [Test] - public async Task SkillsOwnedByTheDeveloper_AreNeverReported(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit), - // A skill from an owner this project has no relationship with: the developer installed it, - // and orphan detection is owner-scoped precisely so it is left alone. - ("web-design", "vercel-labs/agent-skills", "1111111111111111111111111111111111111111") - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).DoesNotContain("PRSKL"); - await Assert.That(output).DoesNotContain("web-design"); - } - - [Test] - public async Task SkillsInstalledUnderTheSameNameFromElsewhere_AreReportedAsUnverifiable( - CancellationToken cancellationToken - ) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - // Same skill name, different repository. Overwriting somebody else's skill is not this SDK's - // call, and calling it drift would be plainly wrong. - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", "someone-else/their-skills", SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0012"); - await Assert.That(output).Contains("someone-else/their-skills"); - await Assert.That(output).DoesNotContain("PRSKL0011"); - } - - [Test] - public async Task ACommittedLockfileWithNothingRestored_RecommendsTheRestoreCommand( - CancellationToken cancellationToken - ) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - // Deliberately no agent skill directory: the shape of a fresh clone. - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0014"); - await Assert.That(output).Contains("bun x skills experimental_install"); - // The command is experimental, so the fallback is named alongside it. - await Assert.That(output).Contains("bun x skills add"); - } - - [Test] - public async Task AgentsSkillsFolder_CountsAsARestoredInstall(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - // '.agents/skills' is the skills CLI's universal install location. It used to be excluded - // from this scan because the SDK's own mirror wrote there, so finding it proved nothing - - // but the mirror is gone, and the folder now means exactly what it appears to mean. - // Excluding it would report PRSKL0014 against a repository whose skills are installed. - Directory.CreateDirectory(Path.Combine(harness.SolutionDirectory, ".agents", "skills")); - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert - .That(output) - .DoesNotContain("PRSKL0014") - .Because("the lockfile has been restored into the CLI's universal location"); - } - - [Test] - public async Task VerifyPurviewSkills_FailsWhenSkillsAreMissing(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - - var (code, output) = await RecommendAsync(harness, "VerifyPurviewSkills", cancellationToken: cancellationToken); - - // This is the gate, not the nag. It is only ever reached because a person or a pipeline - // named it, so escalating a missing skill to a failure breaks nobody by surprise. - await Assert.That(code).IsNotZero().Because(output); - await Assert.That(output).Contains("PRSKL0010"); - await Assert.That(output).Contains("purview-alpha"); - } - - [Test] - public async Task VerifyPurviewSkills_FailsWhenTheInstalledSkillIsDrifted(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - SkillPackageFixtures.WriteLockFile(LockFilePath(harness), ("purview-alpha", Source, OtherCommit)); - - var (code, output) = await RecommendAsync(harness, "VerifyPurviewSkills", cancellationToken: cancellationToken); - - await Assert.That(code).IsNotZero().Because(output); - await Assert.That(output).Contains("PRSKL0011"); - } - - [Test] - public async Task VerifyPurviewSkills_PassesWhenTheInstalledSkillMatchesTheReferencedCommit( - CancellationToken cancellationToken - ) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha"); - GivenSkillsRestored(harness); - SkillPackageFixtures.WriteLockFile( - LockFilePath(harness), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit) - ); - - var (code, output) = await RecommendAsync(harness, "VerifyPurviewSkills", cancellationToken: cancellationToken); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).DoesNotContain("PRSKL"); - } - - [Test] - public async Task Recommendations_InstallNothingAndTouchNoLockfile(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - GivenPackageShipping(harness, "purview-alpha", "purview-beta"); - - await RecommendAsync(harness, cancellationToken: cancellationToken); - - // Recommending is the whole job: the SDK names the command and the developer runs it. - await Assert.That(File.Exists(LockFilePath(harness))).IsFalse(); - await Assert.That(Directory.Exists(Path.Combine(harness.SolutionDirectory, ".claude"))).IsFalse(); - await Assert.That(Directory.Exists(Path.Combine(harness.ProjectDirectory, ".claude"))).IsFalse(); - } - - [Test] - public async Task Recommendations_WorkWithoutAnyNodeTooling(CancellationToken cancellationToken) - { - // An empty PATH is the cleanest way to stand in for a machine with no Node tooling, and it - // does not depend on whether this machine happens to have npx installed. Recommending reads - // the restore graph and a lockfile, both local, so it must work anyway - and it must still be - // able to name the command the developer would need Node for. - using var harness = await ProjectHarness.CreateAsync( - "SkillConsumer", - extraEnv: new Dictionary<string, string> { ["PATH"] = string.Empty }, - cancellationToken: cancellationToken - ); - GivenPackageShipping(harness, "purview-alpha"); - - var (code, output) = await RecommendAsync(harness, cancellationToken: cancellationToken); - - // Unlike InstallPurviewSkills, a missing CLI is never an error here. - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).DoesNotContain("PRSKL0004"); - await Assert.That(output).Contains("PRSKL0010"); - await Assert.That(output).Contains("bun x skills add"); - } - - [Test] - public async Task AnOrdinaryBuild_ReportsNoSkillState(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync( - "SkillConsumer", - extraProps: """ - <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> - <ExcludeMSTelemetryExtension>true</ExcludeMSTelemetryExtension> - """, - cancellationToken: cancellationToken - ); - - // An AGENTS.md beside the solution is what makes the throwaway tree look like a repository: - // the skill targets resolve their root by the same walk-up the rest of the SDK uses. - await File.WriteAllTextAsync( - Path.Combine(harness.SolutionDirectory, "AGENTS.md"), - "# Test repository\n", - cancellationToken - ); - - // A committed lockfile with nothing restored beside it is the state that used to produce - // PRSKL0014 on every interactive build. An ordinary build must now say nothing at all: - // the skills pass is explicit, and a build never reads the restore graph for it. - SkillPackageFixtures.WriteLockFile( - Path.Combine(harness.SolutionDirectory, "skills-lock.json"), - ("purview-alpha", Source, SkillPackageFixtures.DefaultCommit) - ); - - // Verbose, because at quiet verbosity an assertion about messages would pass for the wrong - // reason. - var (success, output, errors) = await harness.BuildAsync( - restore: true, - verbose: true, - cancellationToken: cancellationToken - ); - - await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); - await Assert - .That(output + errors) - .DoesNotContain("PRSKL") - .Because("an ordinary build must not run the skills pass at all"); - } - - [Test] - public async Task OrdinaryBuild_ReportsNothingWhenNoPackageShipsSkills(CancellationToken cancellationToken) - { - // The telemetry packages the SDK adds by default are not part of this scenario and would - // need Central Package Management versions the throwaway solution does not declare. - using var harness = await ProjectHarness.CreateAsync( - "SkillConsumer", - extraProps: """ - <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> - <ExcludeMSTelemetryExtension>true</ExcludeMSTelemetryExtension> - """, - cancellationToken: cancellationToken - ); - - // Verbose, because a recommendation is a high-importance message and the harness builds quiet - // by default - at quiet verbosity an assertion about messages would pass for the wrong reason. - var (success, output, errors) = await harness.BuildAsync( - restore: true, - verbose: true, - cancellationToken: cancellationToken - ); - - await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); - // A real, complete restore-and-build cycle in a repository with no skill-shipping packages is - // silent, and still writes neither the CLI's lockfile nor an agent directory. - await Assert.That(output + errors).DoesNotContain("PRSKL"); - await Assert.That(File.Exists(Path.Combine(harness.SolutionDirectory, "skills-lock.json"))).IsFalse(); - } -} diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillResolutionTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillResolutionTests.cs deleted file mode 100644 index 323a60b..0000000 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillResolutionTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Text.Json; -using Purview.BuildSdk.Harness; -using Purview.BuildSdk.Infra; - -namespace Purview.BuildSdk; - -/// <summary> -/// Verifies the package-to-skill resolution that backs the explicit <c>InstallPurviewSkills</c> -/// workflow: which packages contribute skills, which git source and commit each resolves to, and -/// that resolution never happens as a side effect of an ordinary build. -/// </summary> -sealed class PurviewSkillResolutionTests -{ - static void WriteFakePackage( - string packagesRoot, - string packageId, - string version, - string[] skillNames, - string? repositoryUrl = "https://github.com/purview-dev/purview-example/", - string? repositoryCommit = SkillPackageFixtures.DefaultCommit - ) => - SkillPackageFixtures.WritePackage( - packagesRoot, - packageId, - version, - skillNames, - repositoryUrl, - repositoryCommit - ); - - static void WriteAssetsFile(string assetsPath, params (string Id, string Version)[] packages) => - SkillPackageFixtures.WriteAssetsFile(assetsPath, packages); - - static async Task<IReadOnlyList<Dictionary<string, string>>> ResolveAsync( - ProjectHarness harness, - string packagesRoot, - string assetsPath, - string itemType, - CancellationToken cancellationToken - ) - { - var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( - $"-noconlog -t:ResolvePurviewSkillsTarget -getItem:{itemType} " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\"", - cancellationToken - ); - - await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var jsonStart = stdOut.IndexOf('{', StringComparison.Ordinal); - if (jsonStart < 0) - return []; - - using var document = JsonDocument.Parse(stdOut[jsonStart..]); - if ( - !document.RootElement.TryGetProperty("Items", out var items) - || !items.TryGetProperty(itemType, out var entries) - ) - { - return []; - } - - // The MSBuild task writes a JSON array of objects, each of which is a dictionary of string properties. - return - [ - .. entries - .EnumerateArray() - .Select(entry => - entry - .EnumerateObject() - .ToDictionary(property => property.Name, property => property.Value.GetString() ?? string.Empty) - ), - ]; - } - - [Test] - public async Task ResolvePurviewSkills_ResolvesPackageSkillsToGitSourceAndCommit( - CancellationToken cancellationToken - ) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha", "purview-beta"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, ("Purview.Example", "1.2.3")); - - var resolved = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - - await Assert.That(resolved).Count().IsEqualTo(1); - await Assert.That(resolved[0]["PackageId"]).IsEqualTo("Purview.Example"); - await Assert.That(resolved[0]["PackageVersion"]).IsEqualTo("1.2.3"); - await Assert.That(resolved[0]["Source"]).IsEqualTo("purview-dev/purview-example"); - // The ref is the commit the restored package was built from, so the skills installed match - // the package version rather than whatever is currently on the default branch. - await Assert - .That(resolved[0]["SourceWithRef"]) - .IsEqualTo("purview-dev/purview-example#0123456789abcdef0123456789abcdef01234567"); - // Skill names come from the folders that actually contain a SKILL.md, in a stable order. - await Assert.That(resolved[0]["Skills"]).IsEqualTo("purview-alpha;purview-beta"); - await Assert.That(resolved[0]["SkillCount"]).IsEqualTo("2"); - } - - [Test] - public async Task ResolvePurviewSkills_ExcludesPackagesWithoutSkills(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.WithSkills", "1.0.0", ["purview-alpha"]); - WriteFakePackage(packagesRoot, "Purview.NoSkills", "1.0.0", []); - // A package that is restored but ships nothing agent-related at all. - Directory.CreateDirectory(Path.Combine(packagesRoot, "unrelated.package", "4.5.6")); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile( - assetsPath, - ("Purview.WithSkills", "1.0.0"), - ("Purview.NoSkills", "1.0.0"), - ("Unrelated.Package", "4.5.6") - ); - - var resolved = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - - await Assert.That(resolved).Count().IsEqualTo(1); - await Assert.That(resolved[0]["PackageId"]).IsEqualTo("Purview.WithSkills"); - } - - [Test] - public async Task ResolvePurviewSkills_ResolvesEveryContributingPackage(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage( - packagesRoot, - "Purview.First", - "1.0.0", - ["purview-first"], - "https://github.com/purview-dev/first.git", - "1111111111111111111111111111111111111111" - ); - WriteFakePackage( - packagesRoot, - "Purview.Second", - "2.0.0", - ["purview-second"], - "https://github.com/purview-dev/second", - "2222222222222222222222222222222222222222" - ); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, ("Purview.First", "1.0.0"), ("Purview.Second", "2.0.0")); - - var resolved = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - - await Assert.That(resolved).Count().IsEqualTo(2); - // A '.git' suffix and a missing trailing slash are both normal in nuspec repository metadata - // and must normalise to the CLI's 'owner/repo' form. - await Assert - .That(resolved.Select(entry => entry["SourceWithRef"]).Order()) - .IsEquivalentTo( - (string[]) - [ - "purview-dev/first#1111111111111111111111111111111111111111", - "purview-dev/second#2222222222222222222222222222222222222222", - ] - ); - } - - [Test] - public async Task ResolvePurviewSkills_ReportsPackagesThatCannotBePinned(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - // No repository metadata at all. - WriteFakePackage(packagesRoot, "Purview.NoRepository", "1.0.0", ["purview-orphan"], null, null); - // Repository metadata, but no commit to pin to. - WriteFakePackage( - packagesRoot, - "Purview.NoCommit", - "1.0.0", - ["purview-floating"], - "https://github.com/purview-dev/no-commit", - null - ); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, ("Purview.NoRepository", "1.0.0"), ("Purview.NoCommit", "1.0.0")); - - var resolved = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - var unresolvable = await ResolveAsync( - harness, - packagesRoot, - assetsPath, - "PurviewUnresolvableSkillSource", - cancellationToken - ); - - // Neither is installed from a guessed default branch; both are surfaced instead. - await Assert.That(resolved).IsEmpty(); - await Assert.That(unresolvable).Count().IsEqualTo(2); - await Assert - .That(unresolvable.Select(entry => entry["PackageId"]).Order()) - .IsEquivalentTo((string[])["Purview.NoCommit", "Purview.NoRepository"]); - await Assert.That(unresolvable.All(entry => entry["Reason"].Length > 0)).IsTrue(); - } - - [Test] - public async Task ResolvePurviewSkills_IsDeterministicAcrossRepeatedRuns(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillConsumer", cancellationToken: cancellationToken); - - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - WriteFakePackage(packagesRoot, "Purview.Example", "1.0.0-preview.3", ["purview-zulu", "purview-alpha"]); - - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - WriteAssetsFile(assetsPath, ("Purview.Example", "1.0.0-preview.3")); - - var first = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - var second = await ResolveAsync(harness, packagesRoot, assetsPath, "PurviewSkillSource", cancellationToken); - - await Assert.That(first).Count().IsEqualTo(1); - // A pre-release version resolves like any other, and the skill order does not depend on the - // order the filesystem happened to return the directories in. - await Assert.That(first[0]["PackageVersion"]).IsEqualTo("1.0.0-preview.3"); - await Assert.That(first[0]["Skills"]).IsEqualTo("purview-alpha;purview-zulu"); - await Assert.That(second[0]["Skills"]).IsEqualTo(first[0]["Skills"]); - await Assert.That(second[0]["SourceWithRef"]).IsEqualTo(first[0]["SourceWithRef"]); - } - - [Test] - public async Task OrdinaryBuild_DoesNotInstallSkills(CancellationToken cancellationToken) - { - // The telemetry packages the SDK adds by default are not part of this scenario and would - // need Central Package Management versions the throwaway solution does not declare. - using var harness = await ProjectHarness.CreateAsync( - "SkillConsumer", - extraProps: """ - <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> - <ExcludeMSTelemetryExtension>true</ExcludeMSTelemetryExtension> - """, - cancellationToken: cancellationToken - ); - - // A restore is part of the scenario: the point is that a *complete*, ordinary - // restore-and-build cycle still installs nothing. - var (success, output, errors) = await harness.BuildAsync(restore: true, cancellationToken: cancellationToken); - - await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); - // Installation is explicit: a build must not write the skills CLI's lockfile, and must not - // create an agent directory the CLI owns. - await Assert.That(File.Exists(Path.Combine(harness.SolutionDirectory, "skills-lock.json"))).IsFalse(); - await Assert.That(Directory.Exists(Path.Combine(harness.SolutionDirectory, ".claude", "skills"))).IsFalse(); - await Assert.That(Directory.Exists(Path.Combine(harness.ProjectDirectory, ".claude", "skills"))).IsFalse(); - } -} diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillSolutionTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillSolutionTests.cs index 7d40d71..d91a69a 100644 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillSolutionTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PurviewSkillSolutionTests.cs @@ -18,26 +18,16 @@ sealed partial class PurviewSkillSolutionTests private static partial Regex DryRunInstallLine(); // MSBuild logs this line when a target actually runs, not when a cached result is handed back. - [GeneratedRegex("is building \"[^\"]+\" \\([^)]+\\) on node \\d+ \\(_ListPurviewSkillsCore")] - private static partial Regex ListCoreRun(); - - [GeneratedRegex("No restored package ships agent skills\\.")] - private static partial Regex NoSkillsLine(); - - [GeneratedRegex("Purview\\.Example 1\\.2\\.3 -> purview-dev/purview-example#")] - private static partial Regex ExampleListedLine(); + [GeneratedRegex("is building \"[^\"]+\" \\([^)]+\\) on node \\d+ \\(_CheckPurviewSkillsCore")] + private static partial Regex CheckCoreRun(); /// <summary> - /// Three SDK projects in one solution, each with its own restore graph. The harness's own solution - /// lists projects as files, so this writes one that lists them as projects. + /// Three SDK projects in one solution. The harness's own solution lists projects as files, so this + /// writes one that lists them as projects. /// </summary> static async Task<ProjectHarness[]> CreateSolutionAsync(CancellationToken cancellationToken) { - var first = await ProjectHarness.CreateAsync( - "Alpha", - extraProps: "<ProjectAssetsFile>$(MSBuildProjectDirectory)/fake.assets.json</ProjectAssetsFile>", - cancellationToken: cancellationToken - ); + var first = await ProjectHarness.CreateAsync("Alpha", cancellationToken: cancellationToken); var projects = new List<ProjectHarness> { first }; foreach (var name in new[] { "Bravo", "Charlie" }) @@ -46,9 +36,6 @@ static async Task<ProjectHarness[]> CreateSolutionAsync(CancellationToken cancel await ProjectHarness .For(name) .WithNamespacePrefix("Test") - .AddPropertyRaw( - "<ProjectAssetsFile>$(MSBuildProjectDirectory)/fake.assets.json</ProjectAssetsFile>" - ) .WithSolutionDirectory(first.SolutionDirectory) .BuildAsync(cancellationToken) ); @@ -70,9 +57,6 @@ await File.WriteAllTextAsync( // The repository root resolves from a marker, as it would in a real repository. await File.WriteAllTextAsync(Path.Combine(first.SolutionDirectory, "AGENTS.md"), "# Test", cancellationToken); - foreach (var project in projects) - SkillPackageFixtures.WriteAssetsFile(Path.Combine(project.ProjectDirectory, "fake.assets.json")); - return [.. projects]; } @@ -117,80 +101,10 @@ public async Task ASolution_ElectsTheSameLeaderFromEveryProject(CancellationToke var projects = await CreateSolutionAsync(cancellationToken); using var owner = projects[0]; - var (code, output) = await RunOnSolutionAsync(owner, "-t:ListPurviewSkills", cancellationToken); + var (code, output) = await RunOnSolutionAsync(owner, "-t:CheckPurviewSkills", cancellationToken); await Assert.That(code).IsZero().Because(output); // Each project runs the public target, but only the leader runs the work behind it. - await Assert.That(ListCoreRun().Count(output)).IsEqualTo(1).Because(output); - await Assert.That(NoSkillsLine().Count(output)).IsEqualTo(1); - } - - [Test] - public async Task APackageOnlyOneProjectReferences_IsFoundFromTheSolution(CancellationToken cancellationToken) - { - var projects = await CreateSolutionAsync(cancellationToken); - using var owner = projects[0]; - - var packagesRoot = Path.Combine(owner.SolutionDirectory, "packages"); - SkillPackageFixtures.WritePackage(packagesRoot, "Purview.Example", "1.2.3", ["purview-alpha"]); - // Only the last project - never the leader - references the package, the way a test project - // is often the only one to reference a testing library. - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(projects[2].ProjectDirectory, "fake.assets.json"), - ("Purview.Example", "1.2.3") - ); - - var (code, output) = await RunOnSolutionAsync( - owner, - $"-t:ListPurviewSkills -p:NuGetPackageFolders=\"{packagesRoot}\"", - cancellationToken - ); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(ExampleListedLine().Count(output)).IsEqualTo(1).Because(output); - } - - [Test] - public async Task TwoVersionsOfOnePackage_AreReportedAsAConflict(CancellationToken cancellationToken) - { - var projects = await CreateSolutionAsync(cancellationToken); - using var owner = projects[0]; - - var packagesRoot = Path.Combine(owner.SolutionDirectory, "packages"); - SkillPackageFixtures.WritePackage(packagesRoot, "Purview.Example", "1.0.0", ["purview-alpha"]); - SkillPackageFixtures.WritePackage(packagesRoot, "Purview.Example", "2.0.0", ["purview-alpha"]); - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(projects[1].ProjectDirectory, "fake.assets.json"), - ("Purview.Example", "1.0.0") - ); - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(projects[2].ProjectDirectory, "fake.assets.json"), - ("Purview.Example", "2.0.0") - ); - - var (code, output) = await RunOnSolutionAsync( - owner, - $"-t:ListPurviewSkills -p:NuGetPackageFolders=\"{packagesRoot}\"", - cancellationToken - ); - - await Assert.That(code).IsZero().Because(output); - await Assert.That(output).Contains("PRSKL0017"); - await Assert.That(output).Contains("1.0.0, 2.0.0"); - } - - [Test] - public async Task AnUnrestoredProject_IsNamedInPRSKL0001(CancellationToken cancellationToken) - { - var projects = await CreateSolutionAsync(cancellationToken); - using var owner = projects[0]; - - File.Delete(Path.Combine(projects[1].ProjectDirectory, "fake.assets.json")); - - var (code, output) = await RunOnSolutionAsync(owner, "-t:ListPurviewSkills", cancellationToken); - - await Assert.That(code).IsNotEqualTo(0); - await Assert.That(output).Contains("PRSKL0001"); - await Assert.That(output).Contains("Bravo.csproj"); + await Assert.That(CheckCoreRun().Count(output)).IsEqualTo(1).Because(output); } } diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillTargetIsolationTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillTargetIsolationTests.cs index 8fdb2dd..a6e148b 100644 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillTargetIsolationTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PurviewSkillTargetIsolationTests.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; namespace Purview.BuildSdk; @@ -105,7 +106,7 @@ CancellationToken cancellationToken ) { using var harness = await ProjectHarness.CreateAsync("SkillGuard", cancellationToken: cancellationToken); - SkillPackageFixtures.WriteLockFile( + SkillLockFileFixtures.WriteLockFile( Path.Combine(harness.SolutionDirectory, "skills-lock.json"), ("web-design", "vercel-labs/agent-skills", null) ); @@ -155,4 +156,30 @@ await File.WriteAllTextAsync( await Assert.That(success).IsFalse(); await Assert.That(output + errors).Contains("PRSKL0009"); } + + [Test] + public async Task OrdinaryBuild_DoesNotInstallSkills(CancellationToken cancellationToken) + { + // The telemetry packages the SDK adds by default are not part of this scenario and would + // need Central Package Management versions the throwaway solution does not declare. + using var harness = await ProjectHarness.CreateAsync( + "SkillConsumer", + extraProps: """ + <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> + <ExcludeMSTelemetryExtension>true</ExcludeMSTelemetryExtension> + """, + cancellationToken: cancellationToken + ); + + // A restore is part of the scenario: the point is that a *complete*, ordinary + // restore-and-build cycle still installs nothing. + var (success, output, errors) = await harness.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + // Installation is explicit: a build must not write the skills CLI's lockfile, and must not + // create an agent directory the CLI owns. + await Assert.That(File.Exists(Path.Combine(harness.SolutionDirectory, "skills-lock.json"))).IsFalse(); + await Assert.That(Directory.Exists(Path.Combine(harness.SolutionDirectory, ".claude", "skills"))).IsFalse(); + await Assert.That(Directory.Exists(Path.Combine(harness.ProjectDirectory, ".claude", "skills"))).IsFalse(); + } } diff --git a/src/tests/BuildSdk.IntegrationTests/PurviewSkillUpdateTests.cs b/src/tests/BuildSdk.IntegrationTests/PurviewSkillUpdateTests.cs index 568ca42..11f807e 100644 --- a/src/tests/BuildSdk.IntegrationTests/PurviewSkillUpdateTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PurviewSkillUpdateTests.cs @@ -9,33 +9,20 @@ namespace Purview.BuildSdk; /// </summary> sealed class PurviewSkillUpdateTests { - const string PackageId = "Purview.Example"; - const string PackageVersion = "1.2.3"; - const string PackageSource = "purview-dev/purview-example"; + const string PurviewSource = "purview-dev/build-sdk"; const string OtherSource = "vercel-labs/agent-skills"; const string OldCommit = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee"; static string LockFilePath(ProjectHarness harness) => Path.Combine(harness.SolutionDirectory, "skills-lock.json"); - /// <summary> - /// One package shipping <c>purview-alpha</c>, installed at an old ref, plus two skills from a - /// repository no package speaks for. - /// </summary> - static void GivenInstalledSkills(ProjectHarness harness) - { - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - SkillPackageFixtures.WritePackage(packagesRoot, PackageId, PackageVersion, ["purview-alpha"]); - SkillPackageFixtures.WriteAssetsFile( - Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"), - (PackageId, PackageVersion) - ); - SkillPackageFixtures.WriteLockFile( + /// <summary>One skill pinned at an old ref, plus two skills from a second repository.</summary> + static void GivenInstalledSkills(ProjectHarness harness) => + SkillLockFileFixtures.WriteLockFile( LockFilePath(harness), - ("purview-alpha", PackageSource, OldCommit), + ("purview-buildsdk-testing", PurviewSource, OldCommit), ("web-design", OtherSource, null), ("frontend-design", OtherSource, null) ); - } static async Task<(int Code, string Output)> RunAsync( ProjectHarness harness, @@ -44,13 +31,8 @@ static void GivenInstalledSkills(ProjectHarness harness) CancellationToken cancellationToken = default ) { - var packagesRoot = Path.Combine(harness.SolutionDirectory, "packages"); - var assetsPath = Path.Combine(harness.SolutionDirectory, "obj", "project.assets.json"); - var (code, stdOut, stdErr) = await harness.RunMSBuildAsync( - $"-v:n \"-t:{target}\" " - + $"-p:ProjectAssetsFile=\"{assetsPath}\" -p:NuGetPackageFolders=\"{packagesRoot}\" " - + $"-p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" {extraArguments}", + $"-v:n \"-t:{target}\" -p:PurviewSkillsInstallRoot=\"{harness.SolutionDirectory}\" {extraArguments}", cancellationToken ); @@ -58,7 +40,7 @@ static void GivenInstalledSkills(ProjectHarness harness) } [Test] - public async Task Update_ReAddsEachSource_PinningThePackagesOwnCommit(CancellationToken cancellationToken) + public async Task Update_ReAddsEachSource_AtItsLatest_WithEverySkill(CancellationToken cancellationToken) { using var harness = await ProjectHarness.CreateAsync("SkillUpdate", cancellationToken: cancellationToken); GivenInstalledSkills(harness); @@ -71,16 +53,12 @@ public async Task Update_ReAddsEachSource_PinningThePackagesOwnCommit(Cancellati ); await Assert.That(code).IsZero().Because(output); - // The package's source moves to the commit the referenced version was built from, not to the - // newest skill and not left at the old ref. - await Assert - .That(output) - .Contains($"add \"{PackageSource}#{SkillPackageFixtures.DefaultCommit}\" --skill \"purview-alpha\""); - // Every other source is re-added once, with every skill it has, so a skill the repository has - // gained since the last install comes in too. + // Each unique source once, unpinned, with every skill it has - so a skill the repository has + // gained since the last install comes in too, and the old ref is not kept. + await Assert.That(output).Contains($"add \"{PurviewSource}\" --skill \"*\""); await Assert.That(output).Contains($"add \"{OtherSource}\" --skill \"*\""); await Assert.That(output).DoesNotContain("--skill \"web-design\""); - await Assert.That(output).DoesNotContain(OldCommit + "\" --skill"); + await Assert.That(output).DoesNotContain(OldCommit); // No agents named: the CLI auto-detects, so no agent flag is passed. await Assert.That(output).DoesNotContain("--agent"); // The re-adds are the whole update: 'skills add' refreshes installed skills and adds new ones, @@ -109,34 +87,6 @@ public async Task Update_NamedAgents_BecomeOneFlagEach(CancellationToken cancell await Assert.That(output).DoesNotContain("claude-code,codex\""); } - [Test] - public async Task Update_AddsASkillThePackageHasGained(CancellationToken cancellationToken) - { - using var harness = await ProjectHarness.CreateAsync("SkillUpdate", cancellationToken: cancellationToken); - GivenInstalledSkills(harness); - // The referenced version now ships a second skill the lockfile has never recorded. - SkillPackageFixtures.WritePackage( - Path.Combine(harness.SolutionDirectory, "packages"), - PackageId, - PackageVersion, - ["purview-alpha", "purview-beta"] - ); - - var (code, output) = await RunAsync( - harness, - "UpdatePurviewSkills", - "-p:PurviewSkillsCommand=echo", - cancellationToken - ); - - await Assert.That(code).IsZero().Because(output); - await Assert - .That(output) - .Contains( - $"add \"{PackageSource}#{SkillPackageFixtures.DefaultCommit}\" --skill \"purview-alpha\" --skill \"purview-beta\"" - ); - } - [Test] public async Task Update_DryRun_RunsNothing(CancellationToken cancellationToken) { @@ -173,7 +123,7 @@ public async Task Update_EveryFailedSource_IsNamed(CancellationToken cancellatio await Assert.That(code).IsNotEqualTo(0); await Assert.That(output).Contains("PRSKL0008"); await Assert.That(output).Contains($"re-adding '{OtherSource}' failed"); - await Assert.That(output).Contains($"re-adding '{PackageSource}#{SkillPackageFixtures.DefaultCommit}' failed"); + await Assert.That(output).Contains($"re-adding '{PurviewSource}' failed"); await Assert.That(output).DoesNotContain("source(s) refreshed."); } @@ -181,8 +131,6 @@ public async Task Update_EveryFailedSource_IsNamed(CancellationToken cancellatio public async Task Update_WithoutALockFile_ReportsHowToInstall(CancellationToken cancellationToken) { using var harness = await ProjectHarness.CreateAsync("SkillUpdate", cancellationToken: cancellationToken); - GivenInstalledSkills(harness); - File.Delete(LockFilePath(harness)); var (code, output) = await RunAsync( harness, diff --git a/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs b/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs index c659ed7..167d79c 100644 --- a/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs @@ -61,9 +61,12 @@ public async Task CommentedOutTargetFramework_DoesNotCountAsADeclaration(Cancell // A Condition cannot be evaluated before the project body, so a conditioned declaration must not be // counted: if it never fires, the Roslyn component has to fall back to netstandard2.0 rather than the - // generic .NET default. The author is warned instead of being silently misread. + // generic .NET default. It is not misread either - the body still wins when the condition fires - so + // there is nothing to warn about. [Test] - public async Task ConditionedTargetFramework_KeepsTheRoslynDefaultAndWarns(CancellationToken cancellationToken) + public async Task ConditionedTargetFramework_ThatDoesNotFire_KeepsTheRoslynDefaultWithoutWarning( + CancellationToken cancellationToken + ) { using var harness = await ProjectHarness .For("SourceGeneration") @@ -82,7 +85,59 @@ public async Task ConditionedTargetFramework_KeepsTheRoslynDefaultAndWarns(Cance var properties = await harness.GetPropertiesAsync(cancellationToken, "TargetFramework"); await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); + var (_, stdOut, stdErr) = await harness.RunMSBuildAsync("-t:Build", cancellationToken); + await Assert.That(stdOut + stdErr).DoesNotContain("PurviewConditionedProjectDeclaration"); + } + + [Test] + public async Task ConditionedTargetFrameworks_ThatFires_WinsOverTheRoslynDefault( + CancellationToken cancellationToken + ) + { + // Arrange + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <IsRoslynComponent>true</IsRoslynComponent> + <TargetFrameworks Condition="'$(Configuration)' != 'NeverUsed'">netstandard2.0;net10.0</TargetFrameworks> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await harness.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("netstandard2.0;net10.0"); + await Assert.That(properties["TargetFramework"]).IsEmpty(); + } + + [Test] + public async Task ConditionedIsPackable_StillWarns(CancellationToken cancellationToken) + { + // Arrange + using var harness = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <IsPackable Condition="'$(Configuration)' == 'Release'">true</IsPackable> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act var (_, stdOut, _) = await harness.RunMSBuildAsync("-t:Build", cancellationToken); + + // Assert await Assert.That(stdOut).Contains("PurviewConditionedProjectDeclaration"); } @@ -176,6 +231,9 @@ public async Task PackableProject_AutomaticallyPacksAnalyzerProjectReference(Can <IsPackable>true</IsPackable> <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> <DisableSourceLink>true</DisableSourceLink> + <!-- The stub RuntimeDependency.dll is not a real assembly, so loading it as an analyzer is + CS8034. Only where it is packed is under test. --> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> <ProjectReference @@ -663,6 +721,8 @@ CancellationToken cancellationToken <IsPackable>true</IsPackable> <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> <DisableSourceLink>true</DisableSourceLink> + <!-- The stub SharedRuntime.dll is not a real assembly (CS8034 as an analyzer). --> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> <ProjectReference diff --git a/src/tests/BuildSdk.IntegrationTests/Schemas/THIRD-PARTY-NOTICE.md b/src/tests/BuildSdk.IntegrationTests/Schemas/THIRD-PARTY-NOTICE.md new file mode 100644 index 0000000..61664f0 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/Schemas/THIRD-PARTY-NOTICE.md @@ -0,0 +1,35 @@ +# Third-party notice + +`buildschema.json` is the MSBuild Editor schema meta-schema, copied unmodified from +[mhutch/MonoDevelop.MSBuildEditor](https://github.com/mhutch/MonoDevelop.MSBuildEditor) at commit +`9720806e8fa80cd9c4cb31e12b01c63811f39a75` +(`MonoDevelop.MSBuild/Schemas/buildschema.json`). + +It is vendored so `PropertySchemaTests` can validate `Sdk.props.buildschema.json` offline. To refresh +it, copy the file from a newer commit, update the commit above and `PropertySchemaTests.MetaSchemaCommit`, +and run `just properties-check`. + +```text +MonoDevelop.MSBuildEditor is licensed under the MIT license: + +Copyright (c) Xamarin Inc. +Copyright (c) Microsoft Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` diff --git a/src/tests/BuildSdk.IntegrationTests/Schemas/buildschema.json b/src/tests/BuildSdk.IntegrationTests/Schemas/buildschema.json new file mode 100644 index 0000000..749b123 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/Schemas/buildschema.json @@ -0,0 +1,530 @@ +{ + "$id": "https://github.com/mhutch/MonoDevelop.MSBuildEditor", + "$comment": "This should be draft-07, but anything newer than draft-04 (or https) causes VS to error out when using the schema to validate other JSON files", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Describes the MSBuild schema format", + "type": "object", + "properties": { + "$schema": { "type": "string" }, + "license": { "type": "string" }, + "items": { + "type": "object", + "patternProperties": { + "^([A-Za-z_][A-Za-z\\d_-]*)$": { "$ref": "#/definitions/item" } + }, + "additionalProperties": false + }, + "properties": { + "type": "object", + "patternProperties": { + "^([A-Za-z_][A-Za-z\\d_-]*)$": { "$ref": "#/definitions/property" } + }, + "additionalProperties": false + }, + "targets": { + "type": "object", + "patternProperties": { + "^([A-Za-z_][A-Za-z\\d_-]*)$": { "$ref": "#/definitions/target" } + }, + "additionalProperties": false + }, + "intellisenseImports": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional schemas to be imported only when directly editing the file for which this schema is a sidecar." + }, + "types": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/valueType" }, + "description": "Custom type definitions" + }, + "metadata": { + "type": "array", + "items": { "$ref": "#/definitions/metadataOverride" }, + "description": "Apply metadata to existing item types" + } + }, + "additionalProperties": false, + "definitions": { + "property": { + "description": "Defines a property", + "oneOf": [ + { + "description": "Description of the property", + "type": "string" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the property." + }, + "type": { "$ref": "#/definitions/valueType" }, + "defaultValue": { + "type": "string", + "description": "The default value of the property." + }, + "isList": { "$ref": "#/definitions/valueIsList" }, + "listSeparators": { "$ref": "#/definitions/valueListSeparators" }, + "isLiteral": { + "description": "Permit only literal values (i.e. no expressions). Defaults to false.", + "type": "boolean", + "default": false + }, + "deprecationMessage": { "$ref": "#/definitions/deprecationMessage" }, + "versionInfo": { "$ref": "#/definitions/versionInfo" }, + "helpUrl": { "$ref": "#/definitions/helpUrl" } + }, + "additionalProperties": false + } + ] + }, + "item": { + "description": "Defines an item", + "oneOf": [ + { + "description": "Description of the item", + "type": "string" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the item." + }, + "includeDescription": { + "type": "string", + "description": "Describes the item values listed in the Include attribute e.g. 'source files'." + }, + "type": { "$ref": "#/definitions/valueType" }, + "metadata": { + "type": "object", + "patternProperties": { + "^([A-Za-z_][A-Za-z\\d_-]*)$": { "$ref": "#/definitions/metadata" } + }, + "additionalProperties": false + }, + "packageType": { + "description": "Only applies to items of type `nuget-id`. Restricts the package type." + }, + "isSingleton": { + "description": "Specifies that there may only be a single item of this type. This behavior is rare and unidiomatic.", + "type": "boolean", + "default": false + }, + "deprecationMessage": { "$ref": "#/definitions/deprecationMessage" }, + "versionInfo": { "$ref": "#/definitions/versionInfo" }, + "helpUrl": { "$ref": "#/definitions/helpUrl" } + }, + "additionalProperties": false + } + ] + }, + "metadata": { + "description": "Defines a metadata value", + "oneOf": [ + { + "description": "Description of the metadata", + "type": "string" + }, + { + "type": "object", + "properties": { + "description": { "type": "string" }, + "includeDescription": { "type": "string" }, + "type": { "$ref": "#/definitions/valueType" }, + "defaultValue": { + "type": "string", + "description": "The default value of the metadata." + }, + "isList": { "$ref": "#/definitions/valueIsList" }, + "listSeparators": { "$ref": "#/definitions/valueListSeparators" }, + "isRequired": { + "description": "Whether this metadata is required on items of this type", + "type": "boolean" + }, + "deprecationMessage": { "$ref": "#/definitions/deprecationMessage" }, + "versionInfo": { "$ref": "#/definitions/versionInfo" }, + "helpUrl": { "$ref": "#/definitions/helpUrl" } + }, + "additionalProperties": false + } + ] + }, + "target": { + "oneOf": [ + { + "type": "string", + "description": "Description of the target" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the target" + }, + "deprecationMessage": { "$ref": "#/definitions/deprecationMessage" }, + "versionInfo": { "$ref": "#/definitions/versionInfo" }, + "helpUrl": { "$ref": "#/definitions/helpUrl" } + }, + "additionalProperties": false + } + ] + }, + "metadataOverride": { + "type": "object", + "properties": { + "$appliesTo": { + "description": "Item type or array of item types to which to apply this metadata", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^([A-Za-z_][A-Za-z\\d_-]*)$" + } + }, + { + "type": "string", + "pattern": "^([A-Za-z_][A-Za-z\\d_-]*)$" + } + ] + } + }, + "patternProperties": { + "^([A-Za-z_][A-Za-z\\d_-]*)$": { "$ref": "#/definitions/metadata" } + }, + "additionalProperties": false + }, + "valueIsList": { + "description": "Whether the value permits lists. The default semicolon separator can be overridden with \"listSeparators\" property.", + "type": "boolean", + "default": false + }, + "valueListSeparators": { + "description": "Overrides the list separator characters for this value. Defaults to \";\"", + "type": "string", + "enum": [ ";", ",", ";," ], + "default": ";" + }, + "valueType": { + "description": "The type of the value", + "oneOf": [ + { "$ref": "#/definitions/valueTypeRef" }, + { "$ref": "#/definitions/valueTypeIntrinsic" }, + { "$ref": "#/definitions/valueTypeCustomMinimal" }, + { "$ref": "#/definitions/valueTypeCustom" } + ] + }, + "valueTypeDefinition": { + "oneOf": [ + { "$ref": "#/definitions/valueTypeCustomMinimal" }, + { "$ref": "#/definitions/valueTypeCustom" } + ] + }, + "valueTypeCustomMinimal": { + "type": "array", + "items": { "type": "string" } + }, + "valueTypeRef": { + "type": "object", + "properties": { + "$ref": { + "description": "JSON pointer to an existing type definition", + "type": "string", + "format": "json-pointer" + } + }, + "additionalProperties": false + }, + "valueTypeCustom": { + "type": "object", + "properties": { + "name": { + "description": "Overrides the name displayed for this type in the UI. If not provided, falls back to the name of the `baseType`, otherwise the type is displayed as an anonymous type.", + "type": "string", + "pattern": "^([a-z][a-z\\d-]*)$" + }, + "description": { + "description": "Description of the type", + "type": "string" + }, + "values": { + "description": "The values this type can have", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "description": "Description of the value" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the value" + }, + "deprecationMessage": { "$ref": "#/definitions/deprecationMessage" }, + "versionInfo": { "$ref": "#/definitions/versionInfo" }, + "helpUrl": { "$ref": "#/definitions/helpUrl" }, + "aliases": { + "type": "array", + "items": { "type": "string" }, + "description": "Aliases for this value. They will not be shown in completion, but validation and Find References will consider them to be equivalent." + } + }, + "additionalProperties": false + } + ] + } + }, + "allowUnknownValues": { + "description": "Whether to allow unknown values", + "type": "boolean" + }, + "baseType": { + "description": "If specified, the type's values conform to this underlying type and will inherit validation and commands that apply to that type", + "enum": [ + "guid", + "int", + "warning-code" + ] + }, + "caseSensitive": { + "description": "Whether values should be compared in a case-sensitive way", + "type": "boolean" + }, + "helpUrl": { + "description": "Documentation link to be displayed in UI.", + "type": "string" + }, + "analyzerHints": { + "type": "object", + "properties": { + "GuidFormat": { + "type": "string", + "enum": [ + "N", + "D", + "B", + "P" + ], + "description": "Constrains a `guid`-derived type to a specific `Guid.TryParseExact` format" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "valueTypeIntrinsic": { + "type": "string", + "anyOf": [ + { + "const": "data", + "description": "A string containing arbitrary data" + }, + { + "const": "bool", + "description": "A boolean value" + }, + { + "const": "int", + "description": "An integer value" + }, + { + "const": "string", + "description": "A string value" + }, + { + "const": "guid", + "description": "A GUID value" + }, + { + "const": "url", + "description": "A URL" + }, + { + "const": "version", + "description": "A version of the form `major.minor[.build[.revision]]." + }, + { + "const": "version-suffixed", + "description": "A version that may have a prerelease tag e.g. `1.0.2-beta`" + }, + { + "const": "lcid", + "description": "A Windows language code identifier (LCID)" + }, + { + "const": "culture", + "description": "A .NET culture name" + }, + { + "const": "target-name", + "description": "An MSBuild target name" + }, + { + "const": "item-name", + "description": "An MSBuild item name" + }, + { + "const": "property-name", + "description": "An MSBuild property name" + }, + { + "const": "sdk", + "description": "An MSBuild SDK ID without a version" + }, + { + "const": "sdk-version", + "description": "An MSBuild SDK ID with a version" + }, + { + "const": "label", + "description": "A GUID value" + }, + { + "const": "importance", + "description": "An MSBuild message importance level" + }, + { + "const": "runtime-id", + "description": "A .NET Runtime ID (RID)" + }, + { + "const": "target-framework", + "description": "A .NET target framework in short form e.g. `net8.0-windows`" + }, + { + "const": "target-framework-version", + "description": "A .NET target framework version e.g. `4.8`" + }, + { + "const": "target-framework-identifier", + "description": "A .NET target framework identifier e.g. `.NETStandard`" + }, + { + "const": "target-framework-profile", + "description": "A .NET target framework profile name e.g. `Client`" + }, + { + "const": "target-framework-moniker", + "description": "A .NET target framework in long form e.g. `.NETFramework,Version=3.5,Profile=Client`" + }, + { + "const": "nuget-id", + "description": "A NuGet package ID" + }, + { + "const": "nuget-version", + "description": "A NuGet package version" + }, + { + "const": "project-file", + "description": "The path of an MSBuild file, either absolute or relative to the project file" + }, + { + "const": "file", + "description": "The path of a file, either absolute or relative to the project file" + }, + { + "const": "folder", + "description": "The path of a directory, without a trailing slash, either absolute or relative to the project file" + }, + { + "const": "folder-with-slash", + "description": "The path of a directory, with a trailing slash, either absolute or relative to the project file" + }, + { + "const": "file-or-folder", + "description": "The path of a directory or file, either absolute or relative to the project file" + }, + { + "const": "extension", + "description": "A file value" + }, + { + "const": "configuration", + "description": "A value of the MSBuild `Configuration` property" + }, + { + "const": "platform", + "description": "A value of the MSBuild `Platform` property" + }, + { + "const": "clr-namespace", + "description": "A .NET namespace name, in language-agnostic form." + }, + { + "const": "clr-type", + "description": "A .NET namespace or qualified type name, in language-agnostic form i.e. C# generic syntax is not allowed, but CLR generic syntax ``A`1[B,C]`` is allowed. If you need to support C# generic syntax, use `csharp-type` instead." + }, + { + "const": "clr-type-name", + "description": "A .NET unqualified type name. May not include generics of any form. This is typically used for the name of a type to be generated at build time." + }, + { + "const": "csharp-type", + "description": "C# namespace or qualified type name. May include generics in C# format e.g. `MyNamespace.MyType<int>`. If you need .NET language-agnostic format, use `clr-type` instead." + }, + { + "const": "warning-code", + "description": "A warning code from the compiler, analyzers, MSBuild, etc." + } + ] + }, + "deprecationMessage": { + "type": "string", + "description": "Indicates that the symbol is deprecated and explains why.", + "minLength": 1 + }, + "versionInfo": { + "type": "object", + "properties": { + "kind": { "$ref": "#/definitions/versionKind" }, + "introduced": { + "type": "string", + "description": "The version in which this symbol was introduced. Depending on the value of `versionKind`, this version may refer to MSBuild, the .NET SDK, or a NuGet package." + }, + "deprecated": { + "type": "string", + "description": "The version in which this symbol was deprecated. Only valid when `deprecationMessage` is set. Depending on the value of `versionKind`, this version may refer to MSBuild, the .NET SDK, or a NuGet package." + }, + "ignored": { + "type": "boolean", + "description": "If the symbol is deprecated, indicates that it is ignored and assignments may be removed safely." + } + }, + "additionalProperties": false + }, + "helpUrl": { + "type": "string", + "description": "Documentation link to be displayed in UI.", + "minLength": 1, + "format": "uri" + }, + "versionKind": { + "description": "Explicitly specify what the `since` and `deprecated` versions refer to. A default value is inferred e.g. schemas in the .NET SDK directory default to `DotNetSdk`, and schemas in the MSBuild `bin` directory or bundled with editor default to `MSBuild`.", + "type": "string", + "anyOf": [ + { + "const": "MSBuild", + "description": "The symbol was deprecated or introduced in a specific version of MSBuild" + }, + { + "const": "DotNetSdk", + "description": "The symbol was deprecated or introduced in a specific version of the .NET SDK" + }, + { + "const": "NuGetPackage", + "description": "The symbol was deprecated or introduced in a specific version of a NuGet package" + } + ] + } + } +} \ No newline at end of file diff --git a/src/tests/BuildSdk.IntegrationTests/SdkFolderPackingTests.cs b/src/tests/BuildSdk.IntegrationTests/SdkFolderPackingTests.cs new file mode 100644 index 0000000..23af1d8 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/SdkFolderPackingTests.cs @@ -0,0 +1,137 @@ +using System.IO.Compression; +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// <summary> +/// Verifies where <c>PurviewAutoSdkPack</c> puts each file under a packable project's <c>Sdk/</c> folder, +/// and that a file <em>linked</em> beneath <c>Sdk/</c> lands exactly where a physical file at the same +/// path would. +/// </summary> +/// <remarks> +/// The two paths are implemented separately - physical files by static <c>None</c> items in +/// <c>Sdk.props</c>, linked files by the <c>IncludeLinkedSdkFiles</c> target - so they can disagree. They +/// did: a linked file had its leading <c>Sdk/</c> stripped unconditionally, so <c>Sdk/Props/X.props</c> +/// packed as <c>Props/X.props</c> while a physical file at that path packed as <c>Sdk/Props/X.props</c>. +/// One pack exercises both, side by side, so a rule that changes for one and not the other fails here. +/// </remarks> +sealed class SdkFolderPackingTests +{ + /// <summary> + /// Each <c>Sdk/</c>-relative path, and where it must land in the package. The rules are the + /// <c>PurviewAutoSdkPack</c> table: the four MSBuild folders and <c>.github/</c> move to the package + /// root, so do top-level documents and images, and everything else - nested documents included - + /// stays under <c>Sdk/</c>. + /// </summary> + static readonly (string SdkRelativePath, string PackagePath)[] Layout = + [ + ("build/{0}.props", "build/{0}.props"), + ("buildTransitive/{0}.targets", "buildTransitive/{0}.targets"), + ("buildMultiTargeting/{0}.props", "buildMultiTargeting/{0}.props"), + (".github/{0}.yml", ".github/{0}.yml"), + ("{0}.md", "{0}.md"), + ("{0}.svg", "{0}.svg"), + ("Props/{0}.props", "Sdk/Props/{0}.props"), + ("docs/{0}.md", "Sdk/docs/{0}.md"), + ("{0}.json", "Sdk/{0}.json"), + ]; + + /// <summary>Fills the <c>{0}</c> placeholder in a layout path.</summary> + static string Named(string template, string name) => template.Replace("{0}", name, StringComparison.Ordinal); + + [Test] + public async Task Pack_GivenPhysicalAndLinkedSdkFiles_PacksBothToTheSamePaths(CancellationToken cancellationToken) + { + // Arrange - every path in the layout twice: once as a real file under the project's Sdk/ folder, + // and once as a file outside the project linked to the same Sdk/ path under another name. + var repoRoot = Path.Combine(Path.GetTempPath(), "PurviewSdkTests", Guid.NewGuid().ToString("N")); + var linkedSourceDirectory = Path.Combine(repoRoot, "shared"); + Directory.CreateDirectory(linkedSourceDirectory); + await File.WriteAllTextAsync( + Path.Combine(repoRoot, "package.json"), + /*lang=json,strict*/"""{"name": "test-repo", "version": "0.0.0-test"}""", + cancellationToken + ); + + var links = string.Join( + Environment.NewLine, + Layout.Select( + (entry, index) => + { + var source = Path.Combine( + linkedSourceDirectory, + $"linked-{index}{Path.GetExtension(entry.SdkRelativePath)}" + ); + File.WriteAllText(source, "linked"); + return $"""<None Include="{source}" Link="Sdk/{Named(entry.SdkRelativePath, "Linked")}" />"""; + } + ) + ); + + using var harness = await ProjectHarness + .For("Kit") + .WithSolutionDirectory(Path.Combine(repoRoot, "src")) + .WithProjectFileContent( + $""" + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <IsPackable>true</IsPackable> + <!-- Not what is under test, and the harness's Directory.Packages.props has no version for them. --> + <ExcludePurviewTelemetry>true</ExcludePurviewTelemetry> + <!-- The layout's build/ files are not named for the package id (NuGet's NU5129). --> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> + {links} + </ItemGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + foreach (var (sdkRelativePath, _) in Layout) + { + var physical = Path.Combine( + harness.ProjectDirectory, + "Sdk", + Named(sdkRelativePath, "Physical").Replace('/', Path.DirectorySeparatorChar) + ); + Directory.CreateDirectory(Path.GetDirectoryName(physical)!); + await File.WriteAllTextAsync(physical, "physical", cancellationToken); + } + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + + // Act + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion} -p:RepoRoot=\"{repoRoot}\"", + cancellationToken + ); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var nupkg = Directory + .GetFiles(packageDirectory, "*.nupkg") + .Single(path => !path.EndsWith(".snupkg", StringComparison.Ordinal)); + using var package = await ZipFile.OpenReadAsync(nupkg, cancellationToken); + var entries = package.Entries.Select(entry => entry.FullName).ToHashSet(StringComparer.Ordinal); + + foreach (var (sdkRelativePath, packagePath) in Layout) + { + foreach (var kind in (string[])["Physical", "Linked"]) + { + var expected = Named(packagePath, kind); + await Assert + .That(entries.Contains(expected)) + .IsTrue() + .Because( + $"{kind} Sdk/{Named(sdkRelativePath, kind)} must pack to {expected}; " + + $"the package holds: {string.Join(", ", entries.Order(StringComparer.Ordinal))}" + ); + } + } + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/SdkPackageConsumptionTests.cs b/src/tests/BuildSdk.IntegrationTests/SdkPackageConsumptionTests.cs index 65a63ac..545912d 100644 --- a/src/tests/BuildSdk.IntegrationTests/SdkPackageConsumptionTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/SdkPackageConsumptionTests.cs @@ -47,6 +47,53 @@ await WriteConfigurationFilesAsync( } } + /// <summary> + /// MSBuild Editor loads a sidecar schema from <c><imported file>.buildschema.json</c> beside the + /// file it imports, so the schema only works if it is packed into the same folder as Sdk.props. + /// </summary> + [Test] + [NotInParallel] + public async Task PackedSdk_PropertySchema_IsPackedBesideSdkProps(CancellationToken cancellationToken) + { + // Arrange + var tempRoot = Path.Combine(Path.GetTempPath(), $"PurviewSdkPropertySchema-{Guid.NewGuid():N}"); + var feedDirectory = Path.Combine(tempRoot, "feed"); + Directory.CreateDirectory(feedDirectory); + var sourceSchema = await File.ReadAllBytesAsync( + Path.Combine(SdkPaths.SdkDirectory, "Sdk.props.buildschema.json"), + cancellationToken + ); + + try + { + // Act + var packageVersion = await PackSdkAsync(feedDirectory, cancellationToken); + var packagePath = Path.Combine(feedDirectory, $"Purview.BuildSdk.{packageVersion}.nupkg"); + using var zip = await ZipFile.OpenReadAsync(packagePath, cancellationToken); + var entries = zip.Entries.Select(e => e.FullName).ToArray(); + var schemaEntry = zip.GetEntry("Sdk/Sdk.props.buildschema.json"); + + // Assert + await Assert.That(entries).Contains("Sdk/Sdk.props"); + await Assert + .That(schemaEntry) + .IsNotNull() + .Because( + "MSBuild Editor reads '<path to Sdk.props>.buildschema.json'; the sidecar must be packed at Sdk/Sdk.props.buildschema.json." + ); + + await using var stream = await schemaEntry!.OpenAsync(cancellationToken); + using var packed = new MemoryStream(); + await stream.CopyToAsync(packed, cancellationToken); + await Assert.That(packed.ToArray().SequenceEqual(sourceSchema)).IsTrue(); + } + finally + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } + } + [Test] [NotInParallel] public async Task PackedSdk_ShipsAnalyzers_AndExposesThemToConsumerProject(CancellationToken cancellationToken) @@ -514,6 +561,9 @@ await File.WriteAllTextAsync( <Project> <PropertyGroup> <NamespacePrefix>Proof</NamespacePrefix> + <!-- The proof is the template's Class1 plus a deliberate PDS0003 trigger, and the tests read + those warnings from the output. CoreDefaultsTests pins the TreatWarningsAsErrors default. --> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <Import Sdk="Purview.BuildSdk" Project="Sdk.props" Version="{packageVersion}" /> </Project> diff --git a/src/tests/BuildSdk.IntegrationTests/SkillLockFileFixtures.cs b/src/tests/BuildSdk.IntegrationTests/SkillLockFileFixtures.cs new file mode 100644 index 0000000..c048099 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/SkillLockFileFixtures.cs @@ -0,0 +1,37 @@ +namespace Purview.BuildSdk; + +/// <summary> +/// Writes the skills CLI's own <c>skills-lock.json</c> directly, so the skill target tests stay offline +/// and need no Node tooling. +/// </summary> +static class SkillLockFileFixtures +{ + /// <summary> + /// Writes a <c>skills-lock.json</c> in the schema the skills CLI actually emits: a version, then + /// one flat entry per installed skill carrying its source, the ref it was pinned to (absent when + /// the install was not pinned), the source type and the content hash. + /// </summary> + public static void WriteLockFile(string lockFilePath, params (string Name, string Source, string? Ref)[] entries) + { + var skills = string.Join( + ",\n", + entries.Select(entry => + { + var properties = new List<string> { $"\"source\": \"{entry.Source}\"" }; + if (entry.Ref is not null) + properties.Add($"\"ref\": \"{entry.Ref}\""); + + properties.Add("\"sourceType\": \"github\""); + properties.Add($"\"skillPath\": \".agents/skills/{entry.Name}/SKILL.md\""); + properties.Add( + "\"computedHash\": \"7d83b50f4bb17da819387f9c155978d863eaa457c11821c29931b164ef0d1024\"" + ); + + return $" \"{entry.Name}\": {{ {string.Join(", ", properties)} }}"; + }) + ); + + Directory.CreateDirectory(Path.GetDirectoryName(lockFilePath)!); + File.WriteAllText(lockFilePath, $"{{\n \"version\": 1,\n \"skills\": {{\n{skills}\n }}\n}}\n"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/SkillPackageFixtures.cs b/src/tests/BuildSdk.IntegrationTests/SkillPackageFixtures.cs deleted file mode 100644 index afbc5ae..0000000 --- a/src/tests/BuildSdk.IntegrationTests/SkillPackageFixtures.cs +++ /dev/null @@ -1,125 +0,0 @@ -namespace Purview.BuildSdk; - -// CA1308 prefers ToUpperInvariant for normalisation, but these calls are not normalising for -// comparison: NuGet's global-packages layout is lowercase on disk and in the assets file's "path" -// value, so a fake restored package has to be written to the lowercase folder to be found at all. -#pragma warning disable CA1308 - -/// <summary> -/// Builds the on-disk shapes the skill targets read: a restored package that ships skills, the -/// <c>project.assets.json</c> entry that makes it part of the restore graph, and the skills CLI's -/// own <c>skills-lock.json</c>. Writing these directly keeps the tests offline and free of any -/// dependency on a published package or on Node tooling being present. -/// </summary> -static class SkillPackageFixtures -{ - public const string DefaultCommit = "0123456789abcdef0123456789abcdef01234567"; - - /// <summary> - /// Writes a fake restored package folder. The layout mirrors what - /// <c>PurviewPackRepositorySkills</c> produces: skills under <c>.agents/skills/</c> and the git - /// identity in the nuspec's <c>repository</c> element. - /// </summary> - public static void WritePackage( - string packagesRoot, - string packageId, - string version, - string[] skillNames, - string? repositoryUrl = "https://github.com/purview-dev/purview-example/", - string? repositoryCommit = DefaultCommit - ) - { - var packageRoot = Path.Combine(packagesRoot, packageId.ToLowerInvariant(), version); - Directory.CreateDirectory(packageRoot); - - foreach (var skillName in skillNames) - { - var skillDirectory = Path.Combine(packageRoot, ".agents", "skills", skillName); - Directory.CreateDirectory(skillDirectory); - File.WriteAllText( - Path.Combine(skillDirectory, "SKILL.md"), - $"---\nname: {skillName}\ndescription: Use when exercising skill resolution.\n---\n" - ); - } - - var repositoryElement = - repositoryUrl is null ? string.Empty - : repositoryCommit is null ? $"""<repository type="git" url="{repositoryUrl}" />""" - : $"""<repository type="git" url="{repositoryUrl}" commit="{repositoryCommit}" />"""; - - File.WriteAllText( - Path.Combine(packageRoot, $"{packageId.ToLowerInvariant()}.nuspec"), - $""" - <?xml version="1.0" encoding="utf-8"?> - <package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd"> - <metadata> - <id>{packageId}</id> - <version>{version}</version> - {repositoryElement} - </metadata> - </package> - """ - ); - } - - public static void WriteAssetsFile(string assetsPath, params (string Id, string Version)[] packages) - { - var libraries = string.Join( - ",\n", - packages.Select(package => - $$""" - "{{package.Id}}/{{package.Version}}": { - "sha512": "", - "type": "package", - "path": "{{package.Id.ToLowerInvariant()}}/{{package.Version}}", - "files": [] - } - """ - ) - ); - - Directory.CreateDirectory(Path.GetDirectoryName(assetsPath)!); - File.WriteAllText( - assetsPath, - $$""" - { - "version": 3, - "libraries": { - {{libraries}} - } - } - """ - ); - } - - /// <summary> - /// Writes a <c>skills-lock.json</c> in the schema the skills CLI actually emits: a version, then - /// one flat entry per installed skill carrying its source, the ref it was pinned to (absent when - /// the install was not pinned), the source type and the content hash. - /// </summary> - public static void WriteLockFile(string lockFilePath, params (string Name, string Source, string? Ref)[] entries) - { - var skills = string.Join( - ",\n", - entries.Select(entry => - { - var properties = new List<string> { $"\"source\": \"{entry.Source}\"" }; - if (entry.Ref is not null) - properties.Add($"\"ref\": \"{entry.Ref}\""); - - properties.Add("\"sourceType\": \"github\""); - properties.Add($"\"skillPath\": \".agents/skills/{entry.Name}/SKILL.md\""); - properties.Add( - "\"computedHash\": \"7d83b50f4bb17da819387f9c155978d863eaa457c11821c29931b164ef0d1024\"" - ); - - return $" \"{entry.Name}\": {{ {string.Join(", ", properties)} }}"; - }) - ); - - Directory.CreateDirectory(Path.GetDirectoryName(lockFilePath)!); - File.WriteAllText(lockFilePath, $"{{\n \"version\": 1,\n \"skills\": {{\n{skills}\n }}\n}}\n"); - } -} - -#pragma warning restore CA1308 diff --git a/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs b/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs index 391466f..1310d5b 100644 --- a/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs @@ -466,4 +466,283 @@ public async Task ExplicitTargetFramework_WinsOverASelectedSet(CancellationToken await Assert.That(properties["TargetFramework"]).IsEqualTo("net10.0"); await Assert.That(properties["TargetFrameworks"]).IsEmpty(); } + + // The tests below pin the rule that an explicit TargetFramework or TargetFrameworks always wins over a + // curated set, wherever it is set. The project-text scan only sees an unconditional declaration in the + // project body; a value set before the import, under a Condition, or in a file the project imports used + // to leave the set's TargetFrameworks list underneath it. NuGet then restored the set's list while the + // build compiled the explicit framework, which fails with NETSDK1005. + + [Test] + public async Task TargetFramework_SetBeforeTheImport_WinsOverTheDefaultSet(CancellationToken cancellationToken) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("TargetFramework", "net9.0") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFramework"]).IsEqualTo("net9.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + [Test] + public async Task TargetFramework_SetBeforeTheImportWithASelectedSet_WinsOverTheSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("PurviewTargetFrameworkSet", "All") + .WithPreImportProperty("TargetFramework", "net9.0") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFramework"]).IsEqualTo("net9.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + [Test] + public async Task TargetFrameworks_SetBeforeTheImportWithASingleTargetSet_WinsOverTheSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("PurviewTargetFrameworkSet", "Latest") + .WithPreImportProperty("TargetFrameworks", "net9.0;net10.0") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net9.0;net10.0"); + await Assert.That(properties["TargetFramework"]).IsEmpty(); + } + + [Test] + public async Task TargetFramework_DeclaredWithAFiringCondition_WinsOverTheDefaultSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework Condition="'$(Configuration)' != 'NeverUsed'">net9.0</TargetFramework> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFramework"]).IsEqualTo("net9.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + [Test] + public async Task TargetFramework_DeclaredWithAConditionThatDoesNotFire_KeepsTheDefaultSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework Condition="'$(Configuration)' == 'NeverUsed'">net9.0</TargetFramework> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net10.0;net11.0"); + await Assert.That(properties["TargetFramework"]).IsEmpty(); + } + + [Test] + public async Task TargetFrameworks_DeclaredWithAFiringConditionUnderASingleTargetSet_WinsOverTheSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("PurviewTargetFrameworkSet", "Latest") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFrameworks Condition="'$(Configuration)' != 'NeverUsed'">net9.0;net10.0</TargetFrameworks> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net9.0;net10.0"); + await Assert.That(properties["TargetFramework"]).IsEmpty(); + } + + [Test] + public async Task TargetFramework_SetInAFileTheProjectImports_WinsOverTheDefaultSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <Import Project="TargetFramework.props" /> + </Project> + """ + ) + .BuildAsync(cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(h.ProjectDirectory, "TargetFramework.props"), + """ + <Project> + <PropertyGroup> + <TargetFramework>net9.0</TargetFramework> + </PropertyGroup> + </Project> + """, + cancellationToken + ); + + // Act + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + + // Assert + await Assert.That(properties["TargetFramework"]).IsEqualTo("net9.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + /// <summary> + /// The reconciliation must never fire inside an inner build, where TargetFramework arrives as a global + /// property: the list has to stay identical to the outer evaluation's, or Visual Studio loses the TFM + /// dimension it is building (see MultiTargetSet_ResolvesIdenticallyUnderAnInnerBuildTargetFramework). + /// </summary> + [Test] + public async Task TargetFrameworks_DeclaredWithAFiringConditionUnderAnInnerBuild_KeepsTheList( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("PurviewTargetFrameworkSet", "Latest") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFrameworks Condition="'$(Configuration)' != 'NeverUsed'">net9.0;net10.0</TargetFrameworks> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + var globals = new Dictionary<string, string>(StringComparer.Ordinal) { ["TargetFramework"] = "net9.0" }; + + // Act + var properties = await h.GetPropertiesAsync( + globals, + cancellationToken, + "TargetFramework", + "TargetFrameworks", + "TargetPath" + ); + + // Assert + await Assert.That(properties["TargetFramework"]).IsEqualTo("net9.0"); + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net9.0;net10.0"); + await Assert.That(properties["TargetPath"]).IsNotEmpty(); + } + + /// <summary> + /// The evaluation tests above pin the properties; this one pins the consequence. Before the fix the + /// restore produced assets for the set's frameworks only and the build failed with NETSDK1005. + /// </summary> + [Test] + public async Task TargetFramework_SetBeforeTheImport_RestoresAndBuilds(CancellationToken cancellationToken) + { + // Arrange + using var h = await ProjectHarness + .For("Library") + .WithPreImportProperty("TargetFramework", "net10.0") + .WithPreImportProperty("PurviewTargetFrameworkSet", "Broad") + // The throwaway solution has no Directory.Packages.props entries for the packages the SDK adds. + .WithPreImportProperty("ExcludePurviewTelemetry", "true") + .WithPreImportProperty("DisableSourceLink", "true") + .WithProjectFileContent( + """ + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + </PropertyGroup> + </Project> + """ + ) + .BuildAsync(cancellationToken); + + // Act + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-restore -t:Build", cancellationToken); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut + stdErr).DoesNotContain("NETSDK1005"); + } } diff --git a/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs b/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs index 02d71ef..b9c198a 100644 --- a/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs @@ -142,6 +142,10 @@ CancellationToken cancellationToken "<TestingFramework>None</TestingFramework><SubstituteFramework>None</SubstituteFramework>" + "<TestDataFramework>None</TestDataFramework><OutputType>Exe</OutputType>" + "<ExcludePurviewTelemetry>true</ExcludePurviewTelemetry>" + // The public field the CA1051 exemption needs is also an IDE1006 warning under the shipped + // naming policy, which bans visible instance fields outright. This probe is about the + // exemptions, so that warning must not fail it. + + "<TreatWarningsAsErrors>false</TreatWarningsAsErrors>" + ( disableTestContextRuleSet ? "<DisablePurviewTestContextRuleSet>true</DisablePurviewTestContextRuleSet>" diff --git a/src/tests/BuildSdk.IntegrationTests/VersionDetectionTests.cs b/src/tests/BuildSdk.IntegrationTests/VersionDetectionTests.cs index ed4ef52..e40b18d 100644 --- a/src/tests/BuildSdk.IntegrationTests/VersionDetectionTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/VersionDetectionTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Purview.BuildSdk.Harness; using Purview.BuildSdk.Infra; @@ -103,6 +104,50 @@ await File.WriteAllTextAsync( await Assert.That(await h.GetPropertyAsync("PackageVersion", cancellationToken)).IsEqualTo("4.5.6"); } + [Test] + public async Task Version_SetFromRelativeRootPackageJson_WhenBuiltFromAnotherDirectory( + CancellationToken cancellationToken + ) + { + // A relative RootPackageJson is relative to the project, whatever directory dotnet runs from. + // Resolving it against the working directory instead made one build read a different + // package.json - or none - depending on where it was started. + using var h = await ProjectHarness.CreateAsync( + "MyLibrary", + preImportProps: "<RootPackageJson>../versions/package.json</RootPackageJson>", + cancellationToken: cancellationToken + ); + var versionsDirectory = Path.GetFullPath(Path.Combine(h.ProjectDirectory, "..", "versions")); + Directory.CreateDirectory(versionsDirectory); + await File.WriteAllTextAsync( + Path.Combine(versionsDirectory, "package.json"), + /*lang=json,strict*/ + """{"name": "my-lib", "version": "7.8.9"}""", + cancellationToken + ); + + var elsewhere = Directory.CreateTempSubdirectory("purview-cwd-").FullName; + try + { + var (exitCode, stdOut, stdErr) = await h.RunProcessAsync( + "dotnet", + $"msbuild \"{h.ProjectFilePath}\" -nologo -getProperty:Version -getProperty:RootPackageJson", + elsewhere, + cancellationToken + ); + + await Assert.That(exitCode).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut).Contains("\"Version\": \"7.8.9\""); + await Assert + .That(stdOut) + .Contains(JsonEncodedText.Encode(Path.Combine(versionsDirectory, "package.json")).ToString()); + } + finally + { + Directory.Delete(elsewhere, recursive: true); + } + } + // ---------- auto-discovery via .git marker ---------- [Test] diff --git a/templates/global.json b/templates/global.json index 6846896..e93611f 100644 --- a/templates/global.json +++ b/templates/global.json @@ -3,6 +3,6 @@ "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.1" + "Purview.BuildSdk": "2.1.0" } }